Skip to content

feat(mac): replace the NSMenu with a custom menu bar panel - #237

Merged
dvcdsys merged 8 commits into
developfrom
feat/menubar-panel
Aug 10, 2026
Merged

feat(mac): replace the NSMenu with a custom menu bar panel#237
dvcdsys merged 8 commits into
developfrom
feat/menubar-panel

Conversation

@dvcdsys

@dvcdsys dvcdsys commented Aug 10, 2026

Copy link
Copy Markdown
Owner

What

Implements the menubar-panel design spec on top of the existing launcher functionality: the NSMenu is replaced by a popover-style panel (392pt, 12pt radius, 1.5pt border, full-bleed dividers, square controls, JetBrains Mono/SF Mono data).

state header content
running green RUNNING, :port hero, uptime process/engine/model/version table, Stop + Open Dashboard, toggles
indexing red INDEXING, jobs · projects, sweep bar same as running
starting red STARTING, sentence, sweep bar loader button, toggles greyed
stopped grey STOPPED, consequence sentence installed/app table, Start Server, toggles

How

  • panel_darwin.m — NSStatusItem + borderless nonactivating NSPanel + WKWebView; system window shadow around the HTML's rounded rectangle; dismissal on outside click / key-loss / Esc; JS-reported height drives the window frame.
  • panel.html — the whole look, embedded via go:embed; light+dark from prefers-color-scheme; cixRender(state) in, {action} messages out.
  • panelstate_darwin.go — one JSON contract (buildPanelState), plus uptime (ps -o etime) and LAN-address helpers.
  • menu_darwin.go — all behaviour unchanged (busy gating, first-run routing incl. fix(mac): route the server's no-admin refusal back to the setup wizard #236's bootstrap-refusal path, updates, toggles, reset password); only the rendering side swapped.
  • fyne.io/systray removed; dot-PNG renderer and NSMenu width-cap machinery deleted with their tests.

Follow-up commits (user feedback during live testing)

  • Unified busy loader: Start/Stop keeps the panel open and swaps the button for a loader optimistically on the click; every operation claiming the busy lock (start, stop, both toggles, updates) renders the same three-pulsing-cells loader, labelled per operation ("Stopping the server…", "Applying the setting…", "Installing the update…").
  • In-panel dialogs, Docker-Desktop-style: every osascript window — alerts, confirmations, the wizard's email prompt, the password display — now renders as an area that takes over the panel (#dialog), same tokens and square controls, credentials in a selectable mono block, Enter/Esc answer, closing the panel counts as declining. osascript remains only as the pre-webview fallback. First-launch setup moves to after the panel is up (menu.startupFlow, under the busy claim), so the wizard renders in-panel too and the menu bar icon appears immediately.

Deliberate deviations from the spec

  • STARTING exists (cold start loads the embedding model for minutes) — the spec has no such state.
  • INDEXING keeps :port as the hero and sweeps the progress cells: /status reports active_indexing_jobs, not a percentage, so nothing pretends to be one.
  • The stopped state keeps a small table and the toggles — both work without a running server.
  • The footer's right side is Password…/Updates… (this app's actual utilities) instead of Settings.

Testing

  • go vet/go test ./... green; panel HTML and all dialog kinds verified in-browser against all states, light and dark.
  • Live on this Mac: fresh app launched, status item click opens the panel ("panel opened" in launcher.log proves the whole ObjC bridge round-trip); dialog answer round-trip verified through the message bridge.

Based on #236 (includes its commit).

🤖 Generated with Claude Code

dvcdsys and others added 8 commits August 10, 2026 19:04
Deleting ~/.cix/data does not stay a "missing database" for long: the very
next start attempt recreates an empty cix.db (the server runs migrations
before bootstrapAuth refuses), so needsFirstRun's file-existence check
answers false from then on, and Start showed the raw refusal from
cix-server.err instead of offering setup.

The reliable signal for "no accounts" is the server's own refusal text, so
recognise it: isBootstrapRefusal matches the two bootstrapAuth messages a
user-less database produces, and toggleServer routes a matching
died-on-start log to the same set-up-again offer as a missing database.
Other startup failures still show the log tail — a port clash is not a
reason to offer re-setup.

Also prefill the wizard's email prompt with the address already in
server.env, since a re-run almost always wants the same one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status menu becomes a popover-style panel following the menubar-panel
design spec: status as a coloured header with the port as the hero line,
technical data in a right-aligned mono table instead of disabled menu
rows, one weighted action button per state, square toggles with
consequence hints, and a footer that keeps Quit next to its "server keeps
running" reassurance.

Implementation is a thin Objective-C layer (panel_darwin.m) owning the
NSStatusItem, a borderless nonactivating NSPanel and a WKWebView that
renders one embedded HTML file (panel.html) — the entire look in one
reviewable file, dark mode via prefers-color-scheme for free. Go pushes a
panelState JSON after every poll; the panel posts {action} messages back.
All behaviour (busy gating, first-run routing, updates, network/autostart
toggles, password reset) is unchanged and stays in Go.

Design deviations, deliberate: a STARTING state exists (cold starts load
an embedding model for minutes) and renders with an indeterminate
progress sweep, as does INDEXING — the server reports a job count, not a
percentage, so nothing pretends to be one; the stopped state keeps a
small table (installed runtime + app version) and the toggles, because
both work without a running server.

fyne.io/systray is gone, along with the dot-PNG renderer and the NSMenu
row-width machinery it existed for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reported paper cuts in the new panel:

Start/Stop dismissed the panel like a menu item, hiding exactly the
feedback the click was owed. Server operations (start, stop, both
toggles) now keep the panel open; only actions that hand off to another
surface — dashboard, dialogs, quit — dismiss.

The wait itself looked different per operation: Start/Stop showed nothing
(the panel was gone), the toggles a static "Working…". Every operation
that claims the busy lock now renders the same loader — three pulsing
square cells in the design's blocky language — and beginBusy takes a
label naming the operation ("Stopping the server…", "Applying the
setting…", "Installing the update…"), because the flag says only that
something is slow and the label is what says what. The Start/Stop click
swaps the button for the loader optimistically, on the click itself
rather than a state-push round-trip later; a refused busy claim renders
the real state back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app used to open separate osascript windows for everything — info
popups, confirmations, the setup wizard's email prompt, the password
display — none of which looked anything like the panel. All of them now
render as an in-panel area that takes over the content (panel.html
#dialog): same tokens, same square controls, selectable mono block for
credentials, Enter/Esc answer the dialog, and closing the panel counts
as declining.

Mechanics: a dialog request is pushed to the webview as JSON and the
panel is fronted so it is actually seen; the calling goroutine blocks on
a channel until the answer comes back over the existing action bridge.
Dialogs serialise on a mutex. The osascript layer remains only as the
fallback for the window before the webview exists (and for a
translocated bundle, which never gets a panel).

First-launch setup — the foreign-agent question, the wizard, the runtime
download — moves from main() to after the panel is up (menu.startupFlow,
under the busy claim), so its dialogs render in-panel too and the menu
bar icon appears immediately instead of after a download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first in-panel dialog implementation hid the panel's sections and
morphed the window into the question. What was asked for is the
Docker-Desktop shape: the main window stays where it is, dimmed under a
scrim, and the form floats over it as a modal card — visibly part of the
same window.

The card gets a 120ms rise-in, the scrim swallows clicks to the content
behind (and a click on it declines, like Esc), and when the card is
taller than the panel's own content the panel grows to hold it and
shrinks back after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking Updates… dismissed the panel, ran the check in silence, and
reopened the window with the verdict — a close-then-reopen blink with a
dead gap in the middle. Now nothing except Open Dashboard and Quit
dismisses the panel, and the check puts up a modal 'busy' card at once:
title plus the pulsing-cells loader, no buttons, nothing to answer. The
verdict dialog replaces the card in place.

The same card covers the other silent network waits — the runtime
download inside the wizard, the startup repair, and the reset-password
path's on-demand download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-update for the .app was fully implemented and completely invisible:
the panel showed the server's version but never the app's, and the
up-to-date dialog folded both into one sentence that never said "app" —
so it read as a server-only check, and the launcher's own updater looked
like something nobody had written.

The running table now names both components, `server` and `app`, and the
dialog reports them on separate lines. A development build says outright
that it does not replace itself, which is the actual reason a locally
built app is never offered an update.

Also covers the path with tests, because it had never been executed —
no mac/v* release exists, so the app half of the updater has only ever
seen an empty stream. stageUpdate is split out of updateLauncher (the
swap ends the process, so a test cannot reach past it) and driven against
a fake releases API serving a real signed DMG: the version comparison,
the download, the checksum, the mount and the signature check. A tampered
image must be discarded and leave nothing staged. swap.sh gets its own
test with a real process to wait for and a stub `open` on PATH, covering
both the replacement and its refusal to swap under a live launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extracted from checkForUpdates so both shapes are testable: a released
build reports "cix app / vX.Y.Z" and drops the caveat, and a build from
source keeps it. The caveat is the answer to "why is an update never
offered", so it must appear on exactly one of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dvcdsys
dvcdsys merged commit 9e451ab into develop Aug 10, 2026
2 checks passed
@dvcdsys
dvcdsys deleted the feat/menubar-panel branch August 10, 2026 21:48
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.

1 participant