Skip to content

Menu Bar

tavlean edited this page Aug 1, 2026 · 12 revisions

Menu Bar

The menubar command (src/menubar.tsx). Non-obvious decisions only; the feature list lives in the README.

Not a long-lived process

Menu bar commands are loaded on demand: Raycast runs the command when the user opens the menu or when the 1m background interval fires, waits for isLoading to go false, then unloads it. There is no resident process and no shared React state with the dashboard. Every piece of "live" data is re-derived per run.

Consequence: the menu paints from a Cache snapshot first (instant, possibly stale), then fetchServers() replaces it in the same open. The snapshot lives in src/snapshot.ts and is also written by the dashboard's poll loop, so an open dashboard keeps the menu's first paint fresh.

The refresh reads the persisted pending store (src/pendingStore.ts) first and passes the mid-start cwds to fetchServers as its settlingCwds, ignoring entries past their deadline. That is how the count avoids including a starting project's ephemeral-port helpers, the same call the dashboard makes; see Process Detection, rule 4. The dashboard's snapshot is filtered at the source too, so the first paint agrees.

Unloading also happens shortly after the menu closes, and clicking an item is what closes it, so an onAction gets a grace window of well under a second: enough for a launchCommand, not enough for a kill-wait plus reap plus spawn. Found the hard way (2026-08-01): Restart killed the server (the synchronous SIGKILL landed) but never respawned it, and holding isLoading through the work did not save it — that contract covers root-search and interval launches, not the click path. Hence the rule below: slow work delegates to the dashboard. stayLoadedWhile remains as best-effort cover for cleanup tails nothing depends on (Kill's helper reap). Full write-up in Raycast Quirks.

Why the title count needs pokes

The menu bar title (the running count) only changes when the command re-renders. Waiting for the 1m interval would leave a killed server in the count for up to a minute, so the dashboard pokes the command awake after kill / restart / spawn-detected with launchCommand({ name: "menubar", type: LaunchType.Background }).

The menu bar does NOT poke itself after its own kill actions (PR review finding, 2026-07-02): the handler's refresh() already re-renders the still-loaded command with the fresh count, and Raycast drops overlapping background launches of the same command, so a self-poke is a likely no-op that can only add a redundant process scan. (Restarts no longer refresh from here at all: they delegate to the dashboard, whose spawn flow pokes the menu bar when the replacement lands.)

pokeMenuBar() wraps the call in try/catch because launchCommand throws when the user has disabled the menu bar command, and a disabled menu bar must never break a dashboard kill.

Starts and restarts go through the dashboard

Kills call killServer directly: the SIGKILL is synchronous, so it always lands, and the reapProjectHelpersWhenDown cleanup afterwards (see Process Detection) is best-effort under the click-teardown window. A showHUD ("Killed ") is dispatched in the click tick itself, before awaiting anything else — and that ordering is load-bearing, not stylistic. A version that awaited killServer's exit-wait first (to make the HUD mean "verifiably exited") died at that first await every time: no HUD, no refresh, count frozen until the next interval. The observed rule is that a clicked command survives only while it has Raycast API work in flight, so the first await must be an API call; pure timers first are fatal. The HUD is truthful anyway — a SIGKILL the syscall accepted cannot be refused, only briefly outlived. After the HUD: the exit-wait, then a refresh before the reap so the count drops within a few seconds, then the reap and a second refresh for what it freed. Starts and restarts do not spawn inline — twice over. First, a spawn's outcome takes seconds and needs bind-watching, a pending row, and log capture, all of which the dashboard's spawn state machine already owns (see Spawn Flow). Second, since 2026-08-01 it is established that the menu bar process cannot survive long enough after a click to spawn anything (see the lifecycle note above). So a start item launches the dashboard with the single-target launchContext.spawn payload the picker uses, and Restart (launchRestart) sends the identical payload for its own cwd plus confirmRestarts: false — the spawn flow treats a running target as kill + respawn, and the flag skips the "already running" alert the click already answered. Re-implementing a silent spawn path in the menu bar would fork that machinery for a worse experience, and would not work anyway.

Kill All without a confirm

Per-project "Kill All N Servers" diverges from the dashboard rule that bulk kills confirm first. That is deliberate, decided with the user (2026-07-02): confirmAlert is built for commands running in the Raycast window and is not part of the menu bar surface, so the choice was between no bulk kill, a hidden Option-key variant, and a visible unconfirmed item. The dashboard confirms because ⌃⇧X is one fat-fingerable keystroke from anywhere; a menu click on an item that names its own blast radius is already deliberate. Guardrails instead of a dialog:

  • shown only when the project runs 2+ servers (with one server, the per-server Kill is the same action),
  • the count lives in the label ("Kill Both Servers" for a pair, "Kill All 3 Servers" beyond), bottom placement per the dashboard's high-blast-radius convention (UI Conventions),
  • recovery is cheap by design: the killed project immediately surfaces in the frecency-ranked Start section.

The kill loop uses Promise.allSettled: menu data is a snapshot, so a server can die between menu open and click, and one stale pid must not stop the rest.

Branch tags only when they disambiguate

Since 2026-08-01, a running row shows its branch only when the project's rows span more than one branch: that is worktrees running side by side, and every row of the project then carries its branch, main included, because main is doing real disambiguation work there. A project whose servers all sit on one branch shows none; the tag was repeating "· main" on every single-worktree row. The Start section follows the same rule keyed on names: branch as subtitle only when two visible entries share a project name (they are worktrees of the same project, and the branch is what tells them apart). Ambiguity is judged against the six rows actually rendered, not the full recents list. The dashboard's showBranch preference is deliberately untouched; that surface is a separate discussion with the user.

Fallback icon color is the project's, not the framework's

When a row has no renderable favicon, the tinted glyph takes the dominant color of the project's cached SVG favicon (svgFaviconTint, see Favicons) before falling back to the framework badge color. The badge tint painted every SvelteKit project the same orange, which defeats the point of color in a scan: color should be identity per project, not per framework. The framework tint survives only for projects with no cached favicon at all.

Frecency namespace

The Start section sorts recents with useFrecencySorting (namespace project-starts, key = canonical cwd) and calls visitItem on every start. The Start picker is expected to adopt the same namespace (Roadmap Phase 5), so ranking accrues across surfaces from day one.

Assorted constraints

  • Item titles at the same menu level must be unique or onAction can misfire (documented Raycast behavior). Server submenu titles are host plus port, and ports are unique, so this holds without extra work.
  • interval: "1m": the manifest docs state a 1m minimum (the background-refresh page says 10s; the manifest wins for store safety).
  • updateCommandMetadata({ subtitle: "N running" }) keeps the root-search row informative for free on every run.
  • Menu bar commands are macOS-only, which is fine today and noted against the Windows port in the Roadmap.

Auto-open works the same from every entry point

Menu-bar starts once never opened the browser while identical dashboard starts did. Root cause and fix (2026-07-26): the dashboard reads the extension-level autoOpenInBrowser preference itself, and no caller passes a copy through the launch context anymore. History in Auto-Open Investigation, mechanism in Spawn Flow.

Clone this wiki locally