Skip to content

Menu bar popover: land #118 as a fourth style - #155

Open
krishhgg wants to merge 67 commits into
integration/menu-bar-stylesfrom
integration/menu-bar-popover
Open

krishhgg wants to merge 67 commits into
integration/menu-bar-stylesfrom
integration/menu-bar-popover

Conversation

@krishhgg

@krishhgg krishhgg commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

The menu bar gets a fourth style, Popover, chosen in Settings > General > Menu bar. Pills stays the default.

In Popover style the bar shows the eye, with a short countdown during a session ("1h 59m", or the end time). Any click on it opens a popover: preset chips to start or extend, a custom length, the end ring, the battery's time left, and the Mac's status and warnings. #118 also adds a live sleep check that runs in every style: every 30 s during a session, and when the popover opens, it sets SleepDisabled again if something else turned it off.

This lands #118 by @aretelew at 2cdf3fa. The commit is merged here unchanged, so GitHub marks #118 merged when this one merges and its author keeps the credit. #118 replaces the pills and the right-click menu with the popover for everyone and rebuilds Settings into panes. That conflicts with #147's one-page Settings and with #96's styles, so it can't merge as it is. Here the popover ships as a style, and the Settings rebuild is left out (listed below).

To @aretelew: please don't push to your branch now. A new commit on it isn't in this pull request, so #118 would stay open instead of showing as merged.

The base is integration/menu-bar-styles (#152), which this stacks on. When #152 merges, this pull request's base moves down the stack and nothing else changes.

What ships from #118

  • The popover (d09ac41, 393075a and the design tokens it reads from 44e0d73), in Popover style: its status line, preset chips, Custom row, extend chips, end ring, status and warning rows, browser relaunch rows, Settings and Quit.
  • The battery time estimate and battery row (dda62e3, 2f582a2), and a time typed during an extend is kept.
  • Scrolling above the footer when the popover would pass the bottom of the screen (9d8d8d2).
  • No sleep row while a session holds sleep as expected, in the popover (eefcf34).
  • The hotspot failover row (2cdf3fa).
  • The live sleep check (355fa93), with the fixes below.

Not shipped: the Settings panes, sidebar, search, list editor, number boxes, info buttons and one-button hotspot save (#147's one-page Settings stays); the removal of the pills and the right-click menu in the other styles; the four default presets (the eight on main stay); the palette renames; and #118's docs for a popover-only app, rewritten for four styles.

Conflicts resolved

git show --remerge-diff a15fc53 shows each one. The rule for every file was the base's code for Pills, Compact, Minimal, the right-click menu and Settings, with #118's popover code added beside it.

c6185f4 merges #152's latest head. Both sides added a call after observeMinimalProgress() in the controller's init, and both stay.

Maintainer commits

  • efea40a: builds the merge against the base's three styles: shared names split, the base's countdown transition and hotspot password field back, Menu bar popover and Settings overhaul #118 tests that repeated a base test dropped.
  • 8a122d2: merges Menu bar styles: land #96 (Compact and Minimal) #152 at 751fc19.
  • e26d0e3: removes Menu bar popover and Settings overhaul #118's Settings panes, search, list editor and number boxes, which nothing used after the merge kept the one-page Settings.
  • 3c87372: Popover becomes the fourth style, stored as "popover". Clicks open the popover only in that style, so the other three keep Menu bar: hold-to-end works on macOS 27 and shows on a light bar #93's press handling and their right-click menu. A style change closes the popover and removes its monitors, and a pending start carries over. The popover gives focus back to the app you were in when it closes.
  • aaa5902: the live sleep check reads and sets SleepDisabled only while a session holds sleep, no end or quit is pending, the end time is ahead and session.json is still there. It writes the journal before pmset. 355fa93 could set it again under an end waiting for its retry, after the deadline, or after the backstop restored sleep.
  • 292b25c: keeps the eight default presets.
  • d4bb920: a test that a draining battery with a time to full reads as on the adapter.
  • 75623cc: keeps only the design tokens the popover reads and the base palette, so Pills, Compact and Minimal look as on Menu bar styles: land #96 (Compact and Minimal) #152.
  • 7adf851, 27a8164: comments, docs/spec.md, README and docs/releasing.md for four styles and Popover's release checks.
  • 9895364: a click outside that leaves Insomnia in front gives focus back to the app you were in.
  • c16c291, 54327af: the sleep check checks again after its locked read. An End or Quit after that last check waits for the set and then restores sleep; a test holds the real command runner at launch to pin this.
  • 9f71c48, fc2e244: a chip's duration, to the second, goes with a start or extend handed to another style, so a refused start retries the same length.
  • 69ebedc: PopoverStyleTests wait for the request to finish, not for a two-second deadline.
  • c1540d7: an extend asked for while an end waits for its retry is refused, as a start already is. Before, the extend moved the deadline and the retry then ended the session anyway, discarding the added time. This was on main too, for every style and --session extend.
  • c9757af: the popover's chips, Custom field, Return and ring read the session manager's end state, as Menu bar styles: land #96 (Compact and Minimal) #152's Compact panel does. During any end the header reads Ending… and the controls are off; the ring stays usable while an end waits for its retry.
  • b4bb03f: the "still being ended" refusal for a start or an extend goes away when that end resolves. Before, it stayed up after sleep was restored. Main's start refusal had the same problem.
  • 650c666: the queued extend test holds the lock waits on gates instead of timing them, through an injected lock wait that only tests replace.
  • 14d2240: the popover's "Sleep is not held" row has no em dash.
  • 0012166: a sleep check that read SleepDisabled as 0 and then can't set it again reports "Sleep is not held": a busy lock, an unreadable journal, sudo pmset holding the recovery lock, or a failed read under the lock. Before, it only logged, so the popover dropped its row and no notice came.
  • 4d6759a: a Custom start refused after a close and reopen of the popover puts its value back in the fields, so Retry starts the same length.
  • 8439392: a switch to another app with the pointer on the item closes the popover. Before, it stayed open over the next app.
  • ec548b6: the popover reads the watts and the battery's time left when it opens and when the battery changes, not on every redraw.
  • de9fb9d: the bar host's click-through test compares the host with a plain hosting view, since on macOS 27 a hosting view's own hit test can return nil.
  • b729140: a failed read under the lock warns only if no end, quit or deadline came while it ran.
  • 8bb65f7: only the popover's own Start or Return puts its value back after a reopen. A chip's refused start left an older Custom draft in the fields, and Retry asked for that.
  • 38725cc: a reopen right after a close asks for focus again. The close's yield to the app the user was in could land after the reopen and close it half a second later.
  • aad23f0: an open popover reads the watts and the time left again every 10 seconds. Both can change with the percent and the charging state the same, as when the estimate settles after unplugging.
  • e8f1aa5: a failed set warns only if no end, quit or deadline came while it ran.
  • 495be68: the resign from that yield waits for the reopen's activation even with the pointer already in the popover. A yield counts as on its way for one second.
  • 6ffff65: the same with two quick close and reopen cycles. Every resign within a second of a yield waits, the last one decides, and the second close gives focus back to the app the user was in. The activation after a yield parks no windows.
  • 9f0cae6: the power timer stops itself if the controller is released with the popover up.
  • 57dd7c8: a click inside the popover keeps it open. On a MacBook most clicks there also reached the monitor for clicks in other apps, with the location in the popover's own coordinates, so the popover closed and the click was lost. Custom, the fields and the end ring could not be used with the pointer.
  • c778a68: Esc closes the popover with no field focused, as with the time buttons out. Before, it worked only once a field had the cursor.
  • cb49c1a: Custom puts the cursor in Hours. The focus was asked for before the fields were in the window, and dropped.
  • c9d89f9: the popover's arrow follows the item when the menu bar moves it. After a session ended with the popover up, the arrow pointed about 50 pt beside the eye.
  • 9821a61: a close before the activation its open asked for has landed gives focus back once it lands. A click on the item right after a quick close and reopen left Insomnia in front with nothing open.
  • 499021c: that focus goes to the app the user went to last, if they switched apps before the activation landed. The wait ends a second after the last ask, with nothing left watching.

Each behavior change has a test that fails without it.

Left for the owner

The existing behavior is in place for each.

  1. Default presets: main's eight stay. Menu bar popover and Settings overhaul #118 shipped 30m, 1h, 2h and 4h, which would change every user who never edited theirs.
  2. The battery estimate is in the popover only, not in the other styles' right-click menu.
  3. "Show the countdown in the menu bar" applies to Popover too. Off, its bar is the eye alone.
  4. The ⚠ moved from the shared warning strings into the menu builder. The menu text is the same.
  5. The right-click menu's sleep line still comes from the journal and doesn't reflect a failed live check. The popover shows "Sleep is not held" then.
  6. The popover header shows the end time and the time left ("Awake until 5:30 PM, 2h 14m left") in either countdown style.
  7. Settings' "Default field" and "Default session length" rows show in Popover style but don't change the popover. Its Custom row starts in Hours, and Start stays off until a time is typed (Menu bar popover and Settings overhaul #118's choice: the chips cover a quick start).

Tests

The full suite at 499021c: 1655 tests, 0 failures, on macOS 26.2. The release build with warnings as errors is clean. Hosted CI runs on macOS 26 and 27.

Review

Clean at 499021c, round 10. Round 1 found the sleep check's missing second look and a preset chip's duration lost on a style change. Round 2 found a preset with seconds rounded down on retry, and a gap before the sleep check's command launches, which turned out safe and is now pinned by a test. A review of the merge with #152 found the extend accepted during a pending end. Round 3 found the refusal message staying after the end and a test that depended on timing. Round 4 had no findings at 650c666.

Greptile then found five problems at 650c666, fixed in 14d2240 to ec548b6. Rounds 5 to 9 found the rest of the fixes listed above: a warning during an end or after the deadline, a chip's refused start bringing back an older Custom draft, a battery estimate that went stale, and a series of focus races around a quick close and reopen. Each has a test that fails without its fix.

Evidence

The MacBook run at 650c666 (macOS 26.2) found four problems in the popover:

  • A click inside it closed it, so Custom, the fields and the end ring could not be used with the pointer.
  • Esc did nothing with no field focused.
  • Custom left no cursor in Hours.
  • The arrow pointed beside the eye after a session ended.

57dd7c8 to c9d89f9 fix these, and each fix was checked on the same MacBook with a local build before it was committed. Sessions, the bar's countdown, the sleep check and the other three styles worked at 650c666.

At 499021c, VMs on macOS 27.0.1 and 26.6.2 passed the same checks: clicks inside, Custom focus, Esc, the end ring, the arrow, Command-Tab away, quick close and reopen cycles, the idle rows and the other three styles. The first macOS 26 run's ring holds failed because a system reminder sat over the ring; with it moved aside, 13 of 13 holds ended the session. Details and pictures are in this comment.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; all previous findings are addressed and no new actionable issue was found.

Summary

Adds Popover as a fourth menu bar style while keeping Pills as the default.

  • Popover style puts session controls and Mac status under the menu bar eye.
  • Insomnia checks and restores the sleep setting during active sessions.
  • New session requests wait until a pending end has resolved.

macOS tests and visual checks were not run in this Linux environment.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Menu bar style] --> B[Pills, Compact or Minimal]
  A --> C[Popover]
  C --> D[Session controls and status]
  D --> E[Session manager]
  F[30-second check or popover open] --> G[Read sleep setting]
  G --> H{Sleep protection lost?}
  H -->|Yes| I[Take recovery lock and check again]
  I --> J[Write journal before restoring protection]
  I --> K[Show warning if repair cannot finish]
Loading

Reviews (2) · Last reviewed commit: "Popover: focus goes to the app the user ..." · Reviewed by Greptile

aretelew and others added 30 commits October 13, 2026 15:35
Each editable list (presets, the freeze list, agent apps, tmux targets)
now opens with a search field that filters its rows and a + button
beside it. For app lists the + opens a picker of installed apps with
their icons, searchable by name or bundle identifier, which also adds a
typed bundle identifier that no installed app has. App rows show the
app's icon and mark ids with no app installed.
The Low Power Mode floor, the end floor and the tmux nudge delay were
steppers. Each is now a small box that takes digits only, with its unit
after it, and sets the value on Return or when it loses focus, pulled
into the setting's range. The floors still keep their order through the
Config setters, and the box shows where the setter left the value.
The password field had no visible box, the name saved as you typed and
the password had its own Save button. Both are now bordered fields, and
one Save button under the section writes the name to config.json and
then stores the password under it. The button lights up only while the
name differs from the saved one or the password differs from what the
keychain holds; the password the window loads counts as saved.
Removing the sidebar toggle from the sidebar column made the column
ignore navigationSplitViewColumnWidth and sit at 144 pt, which cut off
"Battery & Thermal". The toggle is now removed on the split view, and
the column opens at its 230 pt ideal width.
Long gray paragraphs under rows and sections now sit behind an info
button beside the setting or section title: hovering shows a one-line
help tag, clicking opens the full text in a popover. Warnings, live
status and one-line descriptions stay on the pane, as the HIG keeps
warnings out of popovers. The popover text lives on each search entry,
so the sidebar search still finds words that are only in a popover.
…g battery charging

An extend leaves the fields editable; its completion now clears them only if they still hold what was submitted. On the adapter with the battery draining, the power row names the adapter and the draw instead of "Charging".
A search pick cancelled by a newer one no longer clears the newer highlight. A number box takes one digit more than its range's top, so 100 in 0...95 becomes 95, not 10. The hotspot status reads the saved password, not a draft. Save's steps move into SettingsView.hotspotSave, with tests that run them against a config and an in-memory keychain. The app picker says "No apps found".
Everything above the footer sits in a scroll view that takes its content's height up to a cap: the screen's usable height less room for the footer and the arrow. A long list of warnings now scrolls, and Settings and Quit stay on screen.
"Lid close", "Agent apps" and "Battery & thermal", as the writing rules ask. The lid-close notice now says to open the Lid close pane.
…ts field

A grouped form right-aligns the text of a field that carries its own
label, so .multilineTextAlignment(.leading) did nothing. The name and
password fields now sit in LabeledContent with their labels hidden,
which keeps the text at the left. The line about using the name from
the Wi-Fi menu moves from under Save to under the name field.
…dited during the load

A load that kept the user's draft also skipped recording what the keychain holds, so the status said the password was missing. The load now records it unless a save began since, which may have replaced it.
SettingsView.runHotspotSave does the whole press: the name to config.json, then the password to the keychain under it or a clear, the field's saving state, and the failover's report. The view calls it, and tests now run it against a config and an in-memory keychain for a new name, a new password, an emptied field and nothing changed.
The line shows in every running session; the journal and the missing live
re-check are how the app knows, not something the popover needs to say.
The countdown already says the Mac is being kept awake; only the two
mismatches (a session not holding sleep, a hold with no session) are
worth a row, and both stay warnings.
Eight presets wrapped the chips onto a second row. Four fit one row with
Custom. A config.json that already lists its presets keeps them.
… reads 0

A start read the bit back once; powerd or another tool could set it back
to 0 later in the session and a closed lid would then sleep the Mac with
the popover none the wiser. The read takes no lock; only a 0 takes the
transaction and runs disablesleep 1 again. A set that fails shows
"Sleep is not held" and posts once per session; the next check retries.
The row reads the Network pane's verdict (HotspotSetupStatus): off with no
hotspot name, needs the password, needs Location access, or ready. The
password is checked with the same prompt-free keychain peek as the Network
pane's recheck, at launch and on each popover open. The row gives way to the
hotspot password warning, which already says what is wrong.
The merge kept the base's status item, menu and Settings, so #118's
popover code and tests referred to names the base does not have.

- Bring back Motion.countdownTransition and the base's
  HotspotPasswordField (buttonTitle, no isSaved), which the base's
  Pills and one-page Settings use. #118's HotspotSaveTests went with
  its Settings save button.
- Give the popover its own helpers where the base already used the
  name: MenuBarModel.popoverInput for the fields, StatusLines.actionRows,
  WiFiStatusName.row, StatusMenu.popoverItems and
  openSettingsFromPopover. The menu keeps StatusMenu.items, so no call
  can reach the other list by its labels.
- Keep the base copy of each test #118 changed and add #118's version
  beside it, named "InThePopover" where the names clash. Drop the three
  that only repeated a base test, and #118's Motion.tick test: the base
  has no per-second countdown animation (1cd9d3a).
- Add CountingStatusSource and MainActor marks the base's isolation
  needs.

The popover is not wired to a style yet, so its tests do not pass at
this commit.
Brings in 934c5a8 ("Ends at" for an end-time countdown in VoiceOver
and Minimal), a635fe2 (the slots test waits on a deadline), 969d24d
(a late Minimal or Compact-ring release ends nothing) and 751fc19
(the releasing.md wording for those holds).

Git merged the files cleanly. a635fe2's waitUntil in UIStatusTests
is the same helper #118 added to that class, so #118's copy is
dropped and its tests use the base's.
The base keeps #147's one-page Settings: no sidebar, panes or search.
The merge left #118's code for its paned Settings in the tree, unused.

- Remove SettingsPanes.swift (panes, search, anchors, help text),
  EditableList.swift, InfoButton.swift and NumberBox.swift, and their
  tests (SettingsSearchTests, EditableListTests, NumberBoxTests).
- Keep HotspotSetupStatus, which the popover's failover row reads: it
  moves to HotspotFailoverRow.swift, and its three tests move to
  HotspotFailoverRowTests unchanged. The comments no longer point at a
  Network pane.
#118 replaced the pills with its popover. On this base the pills,
Compact and Minimal stay as they are, so the popover becomes a fourth
style, stored as "popover" and chosen in Settings like the others.

- The status bar button takes clicks only in Popover style. In the other
  styles it has no action and the SwiftUI view takes the clicks, as on
  the base, so their right-click menu and #93's press handling are
  unchanged. Opening the popover, its chips, Start and hold to end all
  check the style.
- Popover's bar is the eye and, during a session, a short countdown in
  the countdown style: the time left to the minute, or the end time. It
  reads only the text it shows, so it is redrawn once a minute and never
  every second. The "Show the countdown in the menu bar" switch turns it
  off.
- A style change closes the popover, removes its click monitors and the
  button's action, and redraws a pending start's countdown in the new
  style's form. A value typed in the popover goes to the pills model, as
  one typed in the Compact panel does, so a refused start after a switch
  to Pills reopens the pills on it.
- A hold in the popover ends the session only while the popover is up
  in Popover style. The ring is turned off once the popover closes or an
  end is on its way, so a hold that outlives the popover ends nothing.
- The popover gives focus back to the app the user was in when Esc, the
  item or a style change closes it. A close because focus went elsewhere
  leaves focus there, as the Compact panel does (#143).

#118's popover tests now run in Popover style. Tests that pinned the bar
as the eye alone now pin the eye and the short countdown.
A start or extend refused while an end is pending puts up "start
refused: the previous session is still being ended; retrying shortly"
or the extend's line. Nothing took either down when the retry finished
the end or left it to the recovery agent. The line stayed until the
next success, so the popover and the Compact panel kept saying the end
was being retried after the session was gone and sleep was back. The
start line on main does the same.

end() now clears lastError where it clears pendingEnd, and only while
lastError is still one of the two refusals. A newer line, the end's own
failure included, stays.

RecoverySafetyTests: an extend refused for an end waiting on a busy
lock, and a start refused for an end that took the session down but
could not restore sleep, both leave the popover's rows and the Compact
panel once the retry restores sleep. Without the clear both fail. A
retry whose disablesleep 0 fails and hands the end to the agent keeps
its "could not restore sleep" line. An unconditional clear fails that
test.

StillRunningCommandTests used the start refusal as the newer line the
command's exit must leave alone, and expected it to outlast the end
that finishes after the exit. That end now takes it down, so the test
posts a newer failure of its own and checks that one. It still fails
if the exit clears lastError.

The spec says when the reason goes away.
testExtendQueuedBehindAnEndThatIsLeftPendingIsRefused ran with a 1 s
lock timeout that both waits shared. On a loaded runner the extend
could be asked for after the end was already pending, failing the
setup check, or time out before the test freed the lock and report the
lock instead of reaching performExtend's check.

SessionManager takes the wait for the recovery lock as an init
argument, acquireRecoveryLock, which defaults to RecoveryLock.acquire.
Harness.makeManager passes it through. HeldLockWaits in TestSupport
holds the next waits on AsyncGates and gives each up as busy, or tries
the real lock once.

The test now holds the end's wait until both requests are queued and
then gives it up as busy. It frees the lock and only then lets the
extend's wait take it. No timeout decides the order, and the test takes
about 5 ms instead of 1 s. Without performExtend's check it still fails
the same 6 assertions. If the manager skipped the injected wait, the
test fails on the holds left, rather than hanging.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T13:36:26.910558Z 650c666 PR opened
🔒 Security Review ✅ Completed 2026-10-27T13:38:17.669866Z 650c666 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread Sources/Insomnia/UI/StatusSource.swift Resolved
#118's SleepHeldLine.warning, the popover's row for a session whose
sleep check could not set SleepDisabled again, joined its two halves
with an em dash. The repository's writing rule has no em or en dashes
in user-facing strings. It now reads "Sleep is not held, so this
session is not keeping the Mac awake".

The right-click menu's sleep line, from main, keeps its text.
The live sleep check reads SleepDisabled without the lock, and on a 0
takes the transaction, reads again and sets the bit. Two ways out of
that path said nothing: a transaction refused (the recovery lock busy,
a command still running, an unreadable journal) and a read under the
lock that failed. Each logged and returned, so after a read of 0 the
popover kept no "Sleep is not held" row and no "Sleep is back on"
notice posted, though sleep was known to be back on. Closing the lid
then slept the Mac with no word.

Both now put the warning up as a failed set does, once per session,
unless an end was asked for meanwhile or the backstop restored sleep
(maySetSleepAgain). A failed first read still knows nothing new and
leaves the warning as it is; only a read of 1 or a set that holds takes
it down.

SleepCheckTests: a check refused for a busy recovery lock, then one
whose read under the lock fails, each warn with one notification. A
failed first read keeps the warning and a read of 1 takes it down.
Without the change both checks leave sleepNotHeld false.
The popover opens on empty fields unless a refusal is showing. Closed
and opened again while a Custom start was on its way, it cleared the
fields, and the refusal only set its line. The retry then had nothing
typed, so Start or Return asked for the default preset, not the time
the user had typed.

On a refusal the popover's fields get the submitted value back when a
reopen since the submit cleared them, and the fields stay out. A value
typed after the reopen is kept instead.

PopoverStyleTests: a 2 h Custom start refused after a close and reopen
leaves 2 in Hours and the retry starts 2 h; with 3 typed after the
reopen the retry starts 3 h. Without the change the fields are empty
and the retry starts the 4 h default.
The popover is .applicationDefined and closes on Insomnia resigning
active unless the pointer is on the item, since a click on the item
also resigns the app just before the item's action toggles the
popover. A switch by keyboard (Command-Tab) with the pointer resting
on the item resigned the app with no action to follow, and the popover
stayed over the next app until another click.

With the pointer on the item a resign now waits itemClickGrace (0.5 s)
for that click. The action comes with the same mouse-down. If the
popover from that opening is still up and Insomnia is still in the
background after it, the popover closes. Off the item a resign closes
it at once, as before.

PopoverStyleTests: a resign on the item with no click closes the
popover after the wait; a resign followed by the item's click and a
second click that opens it again leaves the new popover up; a resign
off the item closes it at once. Without the wait the first popover
stays up.
StatusPopoverView's body builds its rows through statusItems, which
read the battery's watts and time left from IOKit on every call. Typing
in the fields or the header's minute redrew the popover and read both
again on the main thread, with nothing about the battery changed.

MenuBarModel.popoverPower holds the reading. The popover takes it when
it opens and again when the battery's percent or charging state changes
while it is up, and its rows read it. statusItems still reads IOKit
when it gets no reading, as the tests that call it directly expect.

UIStatusTests: two redraws of an open popover, one after typing, read
the watts no more; a charging change reads them again; a change with
the popover closed reads nothing. Without the change each redraw reads
them.
On macOS 27 a hosting view's own hit test can return nil, so the
check that Pills, Compact and Minimal take their clicks failed there
although they hit-test as on the base. The test now puts the bar's
host and a plain hosting view of the same bar in one window and checks
that the two agree. On macOS 26 it still checks that the plain view
takes the click, and the Popover style's nil holds on both.
The sleep check's read under the lock can fail after an end, a quit
or the deadline came while it ran. Sleep goes back on then, so the
check now asks maySetSleepAgain before it reports "Sleep is not held",
as it does before a set. Before, it posted "Sleep is back on" for a
session that was ending.
The value put back after a reopen came from the fields as they were
when the start was sent, also for a chip. A time typed in Custom
before a 1 hour chip then came back after the chip's start was
refused, and Start retried that time instead. Only the popover's own
Start or Return now puts its fields back.
A close gives focus back to the app the user was in, and that yield
takes a moment to land. A reopen in that moment saw Insomnia still
active and did not ask for focus. The yield then landed with the
pointer on the item, and the popover closed half a second later, or
before 8439392 stayed up without taking typing. A reopen after a
yield now asks for focus again, so the request goes after the yield
and Insomnia ends up in front.
Since ec548b6 the popover read the watts and the time left on open
and on a change in the battery's percent or charging state. Both can
change without either: after unplugging, IOKit's estimate goes from
Calculating... to a time with the percent the same, and the draw
moves. An open popover now reads them again every 10 seconds, and
stops when it closes. A redraw still reads the stored values.
The sleep check's set can fail after an end, a quit or the deadline
came while it ran, and it reported "Sleep is back on" for a session
that was ending. Both of the set's failure paths now ask
maySetSleepAgain first, as the locked read's does since b729140. A
set still running keeps its bookkeeping either way.
38725cc made a reopen right after a close ask for focus again, but the
close's yield could still land with the pointer already moved off the
item into the popover. That resign closed the popover at once. The
reopen now remembers that it asked for focus after a yield, and the
first resign of that opening waits for the activation behind it,
wherever the pointer is.

A yield counts as on its way for one second. After that it has landed
or been turned down, and an open while Insomnia is active asks for
nothing, so windows are not parked for an activation that cannot
raise them.
The run loop keeps a repeating timer. A controller released with the
popover up left the 10 s power timer firing for nothing, since its
weak capture only returned. The timer now invalidates itself when the
controller is gone. A close, a style change and popoverDidClose
already stopped it.
495be68 tied the close's yield to the next opening only. With two
quick close and reopen cycles, Insomnia was in front at the second
open, so that open recorded no app to give focus back to, its close
yielded nothing, and the third opening forgot the first yield. That
yield then landed with the pointer in the popover and closed it at
once. Worse, the second close left focus with Insomnia instead of the
app the user was in.

Now every resign within a second of a yield waits for the activation
behind it, and only the latest waiting resign may close. An open with
a yield on its way gives focus back to that yield's app. The second is
a guess at how long a yield takes to land; it normally lands well
inside it.

The activation asked for after a yield no longer parks windows. A
yield the system turned down brings no activation to put them back,
so they stayed hidden for half a second. A Settings window behind
another app can come forward on a reopen this quick instead.
On a MacBook a click or a hold inside the popover closed it and gave
focus back to the app behind it, so Custom, the fields and the end
ring could not be used with the pointer. Most clicks inside also
reached the global mouse-down monitor, carrying the popover's window
number and a location in the popover's own coordinates. Read as screen
coordinates, a click on the header at x 1231 came out at x 145, far
outside, and the popover closed.

The monitors now convert a click's location to the screen through its
window, and a click on the popover never closes it. A local click with
no window goes by where it is. A resign with the pointer in the
popover waits as one on the item does, and closes only if Insomnia is
still in the background after that.

testAClickInsideThePopoverLeavesItOpen builds the copy the global
monitor got; testAClickInAnotherWindowClosesThePopover adds clicks with
no window. Both fail without this. On the MacBook, 8 of 8 trials of a
click on the header and then Custom kept the popover open, from Finder
and from TextEdit; before, 0 of 10.
On a MacBook Esc left the popover open unless a field had the cursor,
and with only the time buttons out none does. `onExitCommand` runs only
for a focused view. A local key monitor, up while the popover is, now
closes it on Esc in the popover's window and gives focus back, as
before. Esc while a field is composing text is left to the field.

testEscClosesThePopoverWithNoFieldFocused sends Esc through the app
with no field focused; it fails without this. On the MacBook Esc now
closes the popover with Hours focused and with the time buttons only,
from Finder and from TextEdit, and focus goes back to that app.
On a MacBook a click on Custom brought the fields out with no caret,
Accessibility reported the popover itself as focused, and a typed 3
went nowhere. A debug build showed the focus set on the `customShown`
change dropped at once: the fields come in with that same update and
were not in the window yet. The focus is now asked for on the next
turn. With that build Custom put the field editor in Hours, and a 3
typed from Finder or from TextEdit filled it and turned Start on.

testCustomPutsTheCursorInHours pins where the cursor lands. This test
process lays the popover out sooner and did not drop the focus before
the change either, so the MacBook run is what shows the fix.
When a session ended with the popover up, the item shrank from 76 to
26 pt and the popover's arrow ended about 50 pt left of the eye. The
popover follows the item's resize by itself, but the menu bar then
moves the item to keep its right edge, and the popover stayed where it
was. While it is up, a move or resize of the item's window now shows
it again on the item, which puts the arrow back.

testThePopoverFollowsTheItemWhenItMoves takes the popover off the
item's window so the move does not carry it, as the menu bar's does
not; it fails without this. On the MacBook the popover's centre now
matches the item's after an end (1249) and after a start (1224) with
it open; before, it stayed 50 pt off.
With a reopen right after a close, the reopen's activation lands only
after the close's yield. A click on the item in that gap closed the
popover with another app in front, so nothing was given back, and the
activation then brought Insomnia forward with nothing open. The close
now remembers the app in front, and the activation gives focus back to
it when it lands. The same holds for a popover closed before the
activation of a plain open lands.
A close before the activation lands now follows the apps the user goes
to after it, and the activation gives focus back to the last one, not
to the app that was in front at the close. The wait for the activation
ends one landing window after the last ask, removing its observers and
the app it kept, and a released controller removes them too.
@krishhgg

Copy link
Copy Markdown
Owner Author

Full suite at 499021c: 1655 tests, 0 failures, on macOS 26.2 (swift test, 1726 s). The release build with warnings as errors is clean at the same commit.

krishhgg added a commit that referenced this pull request Oct 27, 2026
@krishhgg

Copy link
Copy Markdown
Owner Author

VM runs at 499021c: the popover on macOS 27.0.1 and 26.6.2

The four problems the MacBook run found at 650c666 are fixed on both versions. A click inside the popover keeps it open, Esc closes it and gives focus back, Custom puts the cursor in Hours, and the arrow stays on the eye after the item changes width. Every hold that reached the end ring ended the session. Nothing in the app failed.

Setup. Two VMs on the maintainer's Mac mini, without a battery. All input was synthetic and posted inside the VM. Each run started from default settings and picked Popover in Settings > General > Menu bar.

  • macOS 27.0.1 (26A434) at 1440 × 900. 499021c was built and installed in the VM with its own scripts/install.sh (CDHash 7237d8c8).
  • macOS 26.6.2 (25G83) at 1024 × 768. This VM has Command Line Tools without SwiftUI's macros and can't build the app, so it ran a bundle built from 499021c on a MacBook with scripts/build-app.sh and installed with scripts/install.sh --app (CDHash 38f9f132).
Check macOS 27.0.1 macOS 26.6.2
Opened from Finder and TextEdit, a click on the header text, then Custom 10/10 stay open, Hours shows, Insomnia stays in front 10/10
Custom, then 3 typed without a click 2/2: Hours reads 3, Start turns on, TextEdit stays empty 2/2
Esc with the time buttons, then with Hours focused 2/2 close, k goes to TextEdit 2/2
End ring, each hold on a new 2 h session from --session start 7200 6/6 end it (five 0.65 s holds, one 1.5 s) 13/13, see below
A 90 ms click on the ring Nothing ends Nothing ends
Arrow after a start and an end change the item's width 0 pt off, 2/2 0 pt off, 2/2
Command-Tab to TextEdit with the pointer on the item, on the popover, elsewhere 3/3 close after 0.54 s, 0.53 s and 0.04 s; k goes to TextEdit 3/3, after 0.55 s, 0.54 s and 0.03 s
Clicks on the item 150, 300 and 500 ms apart, three and four clicks, twice each 12/12: three leave it open with Custom then 3 working, four leave it closed with k going to TextEdit 12/12
Idle rows Lid open, Hotspot failover off, Wi-Fi On, no battery row (no battery) Same
Pills, Compact and Minimal, left and right click 3/3 open their own UI, 3/3 open the menu 3/3, 3/3

The ring on macOS 26. In the first macOS 26 run, 0 of 6 holds ended the session. A second run found the cause in the test setup. A macOS reminder asking to allow screen access for the test's remote session sat over the ring, and an event tap showed the down and up going to that window instead of Insomnia. A separate probe read the button as held through every hold. With the reminder moved aside, the same bundle ended:

  • 5/5 popover holds with the Settings window open, 5/5 after a click on the header first, and 3/3 with nothing clicked first. In all 13 the press reached the popover, Insomnia was the active app, and both "session end (user)" and "sleep restored" were logged.
  • 3/3 each with the Pills ring, the Compact panel's ring and a hold on the Minimal eye.
  • 1/1 with a press on End session through Accessibility.

The same MacBook-built bundle on macOS 27 ended 6/6 popover holds with Settings closed and 5/5 with it open, so the build is not a factor.

One extra hold, right after the reminder was moved with the popover still open, didn't end the session. Reopening the popover and holding again did. The cause wasn't found, and that hold is outside the 13 counted.

A hold on macOS 26, before, at the middle of the ring and after:

The popover with Awake until 6:49 PM, 1h 59m left and the end ring

The same popover mid-hold, the ring darker as it fills

The popover after the hold: Not keeping your Mac awake and the preset chips

Custom then 3 on macOS 27:

The popover with Custom open, 3 in Hours with the cursor, and Start on

Folders: macOS 27.0.1 (report, run log) and macOS 26.6.2 (first run, ring diagnosis).

Not covered: a physical mouse or trackpad, display sleep, Spaces, a second display, Reduce Motion, a light menu bar and a battery. Both VMs had keyboard navigation on, so a reopened popover shows the focus ring on Settings….

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants