Skip to content

feat: menu bar / system tray companion app (macOS, Windows, Linux) - #231

Draft
VasiHemanth wants to merge 12 commits into
mainfrom
feat/tray-companion
Draft

feat: menu bar / system tray companion app (macOS, Windows, Linux)#231
VasiHemanth wants to merge 12 commits into
mainfrom
feat/tray-companion

Conversation

@VasiHemanth

@VasiHemanth VasiHemanth commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Adds a menu bar / system tray companion under src-tauri/. It starts the local
server on login so there is no terminal to keep open, keeps today's spend in the
menu bar, and opens the existing dashboard in the browser.

It is a launcher, not a replacement. Every menu item opens the same web UI —
the trace view in particular is where you backtrack through a whole agent run,
which does not collapse into a dropdown, and keeping it on the web keeps it
reachable from any device.

How it works

It spawns one child, node bin/cli.js, rather than reimplementing venv
creation, the SHA-stamped pip install --require-hashes, npm ci and the port
pre-flight in Rust. That keeps one source of truth for how the services start;
a Rust reimplementation would have been a second one that drifts.

The parts that were easy to get wrong

  • Today's figure comes from /analytics?from=<local date>&to=<same>, read
    off total.cost. The date is built from local Y/M/D, and to is a bare
    YYYY-MM-DD because the backend decides whether to merge today's in-flight
    sessions using a lexicographic string compare. by_day is [] on a
    zero-spend day, so total.cost is the only safe field to read.
  • Liveness is GET /. Not /version, which shells out to git and makes an
    outbound HTTPS call to api.github.com. Not /budgets, where reading status
    emits persistent notification records.
  • Interpreters are resolved through a login shell and stored as absolute
    paths. A login-launched GUI app inherits a minimal PATH, so a bare node
    works under tauri dev from a terminal and fails on the first real login —
    the single most likely way this would have broken in the field.
  • It attaches instead of colliding. If a server is already listening it uses
    that one and leaves it running on quit. A server left behind by a previous
    tray process is reaped via a pidfile before the ports are touched, because
    bin/cli.js hard-exits on a busy port and a tray app has no console to print
    that to.
  • Config lives in ~/.tokentelemetry/, not Tauri's app_data_dir(), so
    tray state sits beside every other TokenTelemetry file rather than in
    ~/Library/Application Support.
  • The reqwest client is built without TLS. This app only ever talks to
    127.0.0.1, and dropping TLS makes an outbound HTTPS call impossible rather
    than merely discouraged.

Platform reality

Menu bar text Tooltip
macOS yes yes
Windows no (unsupported by the OS) yes
Linux no no

Tray title text is macOS-only, so the spend figure is the first menu item on
every platform
and the title/tooltip are additive. Linux needs
libayatana-appindicator3-1; on stock GNOME there is no tray at all without an
extension, so tray creation failure logs and degrades instead of aborting — it
keeps supervising and still opens the dashboard.

Verification

  • total.cost for today matched an independent re-bucketing of /sessions
    by local day
    to 7e-15 (float summation order): $55.28579432962963 vs
    $55.28579432962964 across 27 sessions.
  • The running app polled exactly
    GET /analytics?from=2026-08-02&to=2026-08-02&granularity=day and GET /,
    and nothing else — confirmed in the backend access log.
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, and 9 tests
    pass. The .app and .dmg bundle, with LSUIElement present.

Tested on macOS only. Windows and Linux pass fmt, clippy -D warnings and
the test suite in CI, and a dispatched run produced real installers on all three
(tray-macos-latest 3MB, tray-windows-latest 1MB, tray-ubuntu-22.04 78MB) —
but nobody has run them on Windows or Linux hardware. Builds are unsigned, so
macOS needs right-click → Open and Windows will show a SmartScreen warning.

Also here

  • .rs added to the pre-push reviewer's suffix list — without it every Rust
    file in this PR would have shipped past that gate unread.
  • src-tauri/target/ and gen/ gitignored. Cargo.lock is committed: this
    is a binary, not a library. That cuts against the repo's npm lockfile
    convention, which exists for the security-audit.yml job, so it is a
    deliberate exception rather than an oversight.
  • No @tauri-apps/cli entry in the root package.json — that file is a path
    trigger for security-audit.yml, whose npm ci would fail against the
    current zero-dependency lock. CI drives the bundle through npx instead.

Follow-ups not in this PR

  • A Windows Job Object with KILL_ON_JOB_CLOSE would beat the pidfile reap for
    crash cleanup, but it is unsafe code I cannot exercise here.
  • Starting the frontend lazily on first "Open dashboard" click would make login
    cheaper; it needs a --no-frontend mode in bin/cli.js to do cleanly.
  • Signing and notarization have no precedent in this repo.

Review pass (commit 32c3cdb)

A multi-agent adversarial review over the first commit produced 33 candidate
findings; 22 survived an independent refutation pass. Fixed in the follow-up
commit — the ones with a real failure mode:

  • Deadlock. refresh_tray held the menu mutex across set_text, which
    dispatches to the main thread and blocks on a reply. A menu click landing in
    that window hung both threads with no timeout.
  • The IPv6 port probe never ran. "::1:8000" is not a valid SocketAddr
    (v6 literals need brackets), so the parse failed and the loop silently
    skipped it — the cross-stack check the code claimed to do was never happening.
  • reap_stale killed by PID alone. That record survives reboots and PIDs
    get recycled, so it could have SIGTERM'd (or tree-killed, on Windows) an
    unrelated process.
  • Windows quit stalled. Graceful taskkill /T only posts WM_CLOSE, which a
    windowless node process can never receive.
  • The spend figure survived midnight still labelled "Today".
  • The tray icon was invisible off macOS — a black template asset on a dark
    taskbar.
  • TOKENTELEMETRY_DATA_DIR was dropped at login, splitting the data store.
  • No way to quit without a tray (stock GNOME).
  • Plus a single-instance guard, detection moved off the startup path, a
    POSIX-sh fallback for csh/tcsh users, and a CI fix: a paths filter applies
    to tag pushes too, so the release job would never have fired.

Verification of the spawn path

The numeric checks above all ran against attach mode. The spawn half was
exercised separately on macOS, end to end:

  • Cold start created the venv, ran the installs and bound both ports.
  • tray-runtime.json recorded the real PID; ps confirms it is
    node .../bin/cli.js, in its own process group (so setsid took effect),
    with node resolved to an absolute /opt/homebrew path rather than a bare
    name — which is the whole point of the login-shell probing.
  • kill -9 on the tray left cli.js running, as designed.
  • Relaunching reaped that orphan and both its children, leaving exactly one
    cli.js — this is the code path that exercises terminate() +
    wait_ports_free() for real.
  • SIGTERM frees both ports with nothing left listening.

Not yet exercised: a clean Quit through the tray menu (it needs a click I
can't script), and the Windows/Linux teardown paths.


Dropdown panel (commit ae95a58)

Left-click the tray icon opens a 380px panel; the menu moves to right-click. A
native menu can only render text rows, which is why v1 looked sparse next to
what the data already supports.

Shows today's spend as the headline, a 7-day bar chart, tokens / cache-hit /
agent-count tiles, per-agent and per-model breakdowns with share bars, and chips
for skills, MCP servers, subagent types and energy. All of it already existed in
/analytics.

Costs two /analytics calls, issued only when the panel is opened — the 30s
background poll is unchanged. Deliberately avoids /projects (5.3s, all-time)
and /budgets (reading it writes notification records).

A backend bug this works around. The sparkline is built from one multi-day
response rather than seven single-day queries, because a cached single-day query
for 2026-08-02 returned $152.76 while a fresh scan of the same window
returned $6.72. Reproducible, a 22× error: a cache entry built while that
date was still "today" (carrying the live-scan merge) keeps being served as a
historical figure. The multi-day response is internally consistent and agrees
with an independent re-bucketing of /sessions. Worth fixing in the backend on
its own.

Charting: every bar is one hue with identity carried by the text label beside
it, so there is no categorical palette that could fail a colour-vision check.
Today's bar is a darker step of the same hue and the only direct label.

Panel placement on mixed-DPI desktops

Everything macOS reports here — the tray anchor rect, Monitor::position() and
Monitor::size() — is points multiplied by the scale of the monitor it
describes, despite the Physical type name (tao builds all of them with
from_logical(.., scale_factor())). Each value is consistent within its own
monitor but not comparable across monitors of different scale.

On a 2x built-in at (0,0) plus a 1x external to its right, the two displays
therefore overlap in that space, so an anchor's x alone cannot say which screen
it is on. The icon height disambiguates: a menu-bar item is ~22-40pt tall, so
only the correct scale yields a plausible height. An anchor matching nothing —
macOS reports a zero-size rect while the status item is still being drawn —
falls back to the primary display for both the scale and the clamp, so the panel
always lands on a real screen.

The move is applied asynchronously, and reading the position straight back
returns the value from the previous show, so the position is set again after
show().

TT_TRAY_SELFTEST="x,y,w,h;…" replays synthetic anchors through the real
placement path and logs computed vs settled for each; TT_TRAY_DEBUG=1 prints
the read-back on ordinary clicks. Placement depends on the live monitor
arrangement, so it cannot be unit-tested and otherwise needs one click per
display per attempt. Verified 6/6 on a 2x built-in plus a 1x external — both
displays, alternating between them, and both edge clamps.

Note: this workflow stopped running on pushes

Tray app ran on pull_request for the first pushes to this branch and then
stopped firing on later ones, leaving the head commit with no checks at all
despite src-tauri/** changes. Runs triggered by workflow_dispatch still
work, and that is how the cross-platform check was run here. Worth working out
before merge, otherwise tray pushes go unchecked silently.

The poll interval is a CPU decision, not a freshness one

Caught by the machine running hot with the tray installed.

/analytics is served from the backend's session cache, whose TTL is 30s. A
miss runs _scan_sessions_sync across every session of every harness plus a
history write — on a ~1,200 session history that measured 25-35 CPU-seconds.
There is no serve-stale-and-refresh path, so a client polling at or slower than
30s misses every single time, and the poll interval is really "how often do
I force a full rescan".

The original 30s default therefore asked for scans faster than one could finish
and the backend never returned to idle. Measured per 45s of wall clock:

backend CPU-seconds
tray polling at 30s 56.7 (~126% of a core)
no tray at all (clean 60s baseline) 0

The backend is completely idle otherwise, so this was entirely the tray's doing.

Now: default 300s, a 60s floor, and any value below the floor falls back to the
default rather than the floor — every existing tray.json says 30, so raising
the default alone would have left installs on the old value, and snapping to 60s
would still rescan for a large part of every minute. The relationship between
the interval and the cache TTL is a compile-time assertion, not a test, because
the failure mode is silent: nothing breaks, the machine just gets hot.

Freshness is unaffected in practice — opening the panel always refreshes on the
spot, so the interval only governs the menu bar number while nobody is looking
at it.

Worth fixing in the backend separately: serving stale data while refreshing
in the background would make the cost of a poll proportional to how often the
data actually changes, rather than to how often anyone asks.

VasiHemanth and others added 12 commits August 2, 2026 22:48
Adds a small Tauri v2 desktop app under src-tauri/ that starts the local
server on login and keeps today's spend in the menu bar. It is a launcher,
not a replacement for the dashboard — every menu item opens the same web UI,
because the trace view and analytics need real screen space.

It spawns one child, `node bin/cli.js`, rather than reimplementing venv
creation, the SHA-stamped pip install, npm ci and the port pre-flight in
Rust. One source of truth for how the services start.

Notes on the parts that are easy to get wrong:

- Today's figure comes from /analytics?from=<local date>&to=<same>, read off
  total.cost. The date is built from local Y/M/D, and `to` is a bare
  YYYY-MM-DD because the backend decides whether to merge today's in-flight
  sessions with a lexicographic string compare. by_day is empty on a
  zero-spend day, so total.cost is the only safe field.
- Liveness is GET /. Not /version (it makes an outbound call to
  api.github.com) and not /budgets (reading it writes notification records).
- Interpreters are resolved through a login shell and stored as absolute
  paths. A login-launched GUI app inherits a minimal PATH, so a bare `node`
  resolves under `tauri dev` and fails on the first real login.
- If a server is already listening it attaches instead of starting a second
  one, and leaves it running on quit. A server left by a previous tray
  process is reaped via a pidfile before the ports are touched.
- Tray title text is macOS-only, so the spend figure is the first menu item
  on every platform and the title/tooltip are additive.

The reqwest client is built without TLS: this app only ever talks to
127.0.0.1, and dropping TLS makes an outbound HTTPS call impossible rather
than merely discouraged.

Verified on macOS: total.cost matches an independent re-bucketing of
/sessions by local day to 7e-15 (float summation order), the app polls the
documented URL and nothing else, and the .app/.dmg bundle builds with
LSUIElement set. Windows and Linux are type-checked and bundled in CI but
not yet run on real hardware.

Also adds .rs to the pre-push reviewer's suffix list, which would otherwise
have let all of this ship unreviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review pass over the previous commit surfaced 33 candidate
defects; 22 survived verification. The ones with a real failure mode:

Deadlock. refresh_tray held the `menu` mutex across set_text/set_enabled,
which dispatch to the main thread and block on a reply. If the main thread
was itself in refresh_tray (a menu click) waiting for that same mutex,
neither side could progress and nothing timed out. MenuItem is an Arc
newtype, so the handles are cloned out and the guard dropped first.

IPv6 port probe never ran. "::1:8000" is not a valid SocketAddr — v6
literals need brackets — so the parse failed and the loop silently
`continue`d every time. The probe is now built from IpAddr, which is what
made the cross-stack check the comment claims worth having. Test added,
plus a bound-port test.

reap_stale killed by PID alone. The record routinely survives a reboot, and
PIDs are recycled aggressively on Windows and wrap at pid_max on Linux, so
this could SIGTERM (or on Windows tree-kill) an unrelated process. Now: skip
entirely when neither recorded port is listening, and confirm the process is
really our supervisor before signalling. Windows pid lookup also parses the
CSV rather than substring-matching, which false-positived on the memory
column.

Windows quit stalled. A graceful `taskkill /T` only posts WM_CLOSE, and a
CREATE_NO_WINDOW node process has no window — so it could never succeed and
just burned the whole stop budget before escalating. Goes straight to the
tree kill, matching bin/cli.js.

Stale figure after midnight. The cached spend carried no date, so the last
value of the day kept being labelled "Today" — indefinitely if the backend
was down. It now stores (local date, cost) and every reader goes through a
date check.

Tray icon invisible off macOS. The template asset is pure black plus alpha;
Windows and Linux ignore the template flag, so it was a black glyph on a
dark taskbar. Ships the colour icon there.

Data store split. A GUI process at login inherits no shell exports, so a
user with TOKENTELEMETRY_DATA_DIR in their rc file got a second empty store.
Both it and TOKENTELEMETRY_HOME are read from the login shell and forwarded.

No way to quit without a tray. Stock GNOME shows no icon at all without an
AppIndicator extension, and closing Preferences only hid it. Adds a Quit
button and command.

Also: single-instance guard (a second tray reaped the first one's live
services); detection moved off the startup path (it runs the login shell up
to four times per command at 6s each, delaying the icon at login); a
POSIX-sh fallback so csh/tcsh users get any result at all; CREATE_NO_WINDOW
on helper spawns; bounded wait in stop() so Quit cannot hang the UI thread;
restart and checkout-clearing guarded against states where they strand a
running child; CSP widened for Tauri's IPC origin; and the CI tag trigger
fixed — a `paths` filter applies to tag pushes too, so releases would never
have fired.

Re-verified on macOS: today's total still matches an independent
re-bucketing of /sessions by local day (66.72878289259259 vs
...258 across 28 sessions), the single-instance guard drops the second
process, and clippy -D warnings, fmt, 12 tests and the bundle all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-ups from a review of the previous fix commit.

Autostart pointed at a stale path. All three platforms record an ABSOLUTE
binary path when the login item is created, so dragging the .app out of
target/release/bundle into /Applications leaves the LaunchAgent dangling.
is_enabled() keeps returning true, so Preferences would show "launch at
login: on" while login silently did nothing — the headline feature failing
with the UI insisting it works. Re-asserted on every start.

reap_stale could skip a real orphan. The port-free early return assumed
"no listener means nothing to reap", but a supervisor that died during its
install phase (npm ci / pip install) has not bound anything yet and will
bind both ports seconds later. Keyed on the pid being dead instead, which
is the condition that actually means there is nothing to signal.

tasklist_row abandoned the whole lookup on one unparseable row: `?` inside
the loop returns None from the function rather than skipping the line, so
pid_alive and pid_is_our_supervisor could report a live process missing for
reasons unrelated to it, silently disabling reaping on Windows.

Windows identity check now reads the real command line via Get-CimInstance
and looks for cli.js, matching the strength of the Unix `ps` check, with the
image-name check kept as a fallback. tasklist alone cannot tell our node
from any other node the user is running.

Verified the spawn path end to end on macOS for the first time (everything
before this exercised attach mode only): cold start built the venv, ran the
installs and bound both ports; tray-runtime.json recorded the real pid;
`ps` confirms it is `node .../bin/cli.js` with its own process group (setsid
worked) and node resolved to an absolute /opt/homebrew path; SIGKILLing the
tray left cli.js running as designed; relaunching reaped that orphan and
both its children, leaving exactly one cli.js; and SIGTERM frees both ports
with no leftovers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI builds installers on all three platforms, but nobody has run the Windows
or Linux binaries. "Built in CI" and "tested" are not the same claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Left-clicking the tray icon now opens a 380px panel instead of only a text
menu; the menu moves to right-click. A native menu can only render text rows,
which is why the first version looked sparse next to what the data supports.

The panel shows today's spend as the headline, a 7-day bar chart, tokens /
cache-hit / agent-count tiles, per-agent and per-model breakdowns with share
bars, and chips for skills, MCP servers, subagent types and energy. All of it
already existed in /analytics and was simply not surfaced.

Cost: two /analytics calls, issued ONLY when the panel is opened. The 30s
background poll is unchanged, so idle cost is exactly what it was.

Deliberately not used:
- /projects takes 5.3s and is all-time, not today.
- /budgets emits persistent notification records as a side effect of being
  read, which must never sit behind a tray click.

The sparkline is built from ONE multi-day response rather than N single-day
queries, and that is load-bearing. A cached single-day query for 2026-08-02
returned $152.76 while a fresh scan of the same window returned $6.72 — a 22x
error, reproducible, caused by a cache entry built while that date was still
"today" (and so carried the live-scan merge) continuing to be served as a
historical figure. The multi-day response is internally consistent and agrees
with an independent re-bucketing of /sessions. Worth fixing in the backend
separately; this avoids depending on it.

Charting decisions: every bar is one hue and identity is carried by the text
label beside it, so there is no categorical palette that could fail a
colour-vision check. Today's bar is a darker step of the same hue plus the
only direct label — labelling all seven would make it a table. Rounded
data-ends anchored to the baseline, a 2px surface gap between bars, recessive
axes, tabular figures so the amount does not jitter as it ticks, and dark-mode
steps chosen against the dark surface rather than auto-flipped.

Routes from the panel go through a fixed allow-list rather than string
interpolation, so nothing the webview sends can steer the browser.

macOSPrivateApi is on because the panel is a transparent window; without it
the rounded sheet renders on an opaque rectangle.

Verified on macOS: clippy -D warnings, fmt, 17 tests (5 new covering window
maths across a month boundary, spend-ordered slices, zero-spend days and
today-flagging), and the .app/.dmg bundle. Panel markup rendered against live
data for today ($133.44) and the week ($1,316 / 27.7M tokens).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lf-hiding

Three defects found by running it on a real dual-monitor Mac
(main 2940x1912 @2x at (0,0), external 2560x1440 @1x at (1470,0)).

Coordinate spaces were mixed. Tauri reports Monitor::position() in POINTS but
Monitor::size() in PHYSICAL pixels, and the tray rect arrives tagged
`Physical` while its numbers are already points. Scaling the anchor by the
panel window's own scale factor — which is the scale of whatever monitor the
panel currently sits on, not the one holding the icon — computed a 760pt wide
window for an icon on the 1x display and placed the panel 190pt to its left.
It also self-corrected on the second click, once the window had migrated to
the other monitor, which made it look random. Everything is now computed in
points, the width comes from the config rather than the live window, and the
result is clamped to the monitor containing the icon.

The panel hid itself the instant it appeared. macOS keeps focus on the status
item when you click it, so the popover receives a blur immediately; acting on
that is indistinguishable from the panel never opening. There is now a grace
period, a re-check after the focus settles, and a `panel-shown` event from
Rust so a reopened panel refreshes rather than showing stale numbers.

Content taller than the window rubber-banded the whole sheet, dragging the
rounded card away from its own window edge. Only the breakdown lists scroll
now, with the headline, chart, tiles and footer pinned, overscroll disabled,
and the window raised to 620pt so there is usually nothing to scroll.

Also adds a "Show panel" menu item. The tray click event is not deliverable on
every desktop (on Linux a left click commonly opens the menu regardless), so
the panel must not be reachable only that way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tray rect arrives as points multiplied by the scale of the monitor the
icon sits on. With displays of different scale those ranges OVERLAP in that
space, so x alone cannot identify the display. Measured on a real dual setup:
main 0..2940 @2x, external 1470..4030 @1x — an anchor at x=1784 is a valid
coordinate on BOTH.

The icon height resolves it. A menu-bar item is ~22-40pt tall on every Mac, so
only the correct scale yields a plausible height. Both observed anchors on the
same machine, each ambiguous by x and unambiguous by height:

  raw (1784,0) 176x66 -> /2 = 88x33pt  main display     -> panel (746,39)
  raw (3462,0)  76x30 -> /1 = 76x30pt  external display -> panel (3310,36)

Previously the second case worked and the first placed the panel on the wrong
display entirely, which is why it looked like it opened somewhere random: the
answer depended on which monitor the panel happened to be on at the time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mixed-DPI anchor fix computed the right coordinates, logged them, and
then never called set_position — the call was dropped while reworking the
monitor-selection logic. The panel kept opening wherever it last sat, which
looks exactly like the placement bug it was meant to fix, and the log line
read "-> panel at (x,y)" for a move that never happened.

Move the coordinate maths into a pure `panel_origin` so it is covered by
tests instead of by clicking the icon and looking. The cases are real
measurements from a 2x built-in plus a 1x external whose x ranges overlap in
the space the tray anchor is reported in, including one anchor taken
verbatim from a session log, plus the clamp and fallback paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e OS

Two gaps in the previous fix:

An anchor that matches no monitor fell back to 1x AND skipped the clamp.
macOS reports a zero-size or full-menu-bar rect while the status item is
still being drawn, so this path is reachable, and on a 2x-only Mac an icon
near the right edge resolved to a point past the right edge of the only
screen — the panel opens off-screen, which is the "clicking does nothing"
symptom. Fall back to the primary display for both the scale and the clamp.

The placement tests could only prove the maths was stable, not that it was
applied: they assert values derived from the same formula the function uses,
so the build that never called set_position would have passed them. Add a
TT_TRAY_DEBUG read-back that logs the position the OS reports after showing
the window and flags a MISMATCH against the computed one. Placement depends
on the user's monitor arrangement, so a wrong-place report needs a way to
capture the raw numbers from the machine that saw it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The move is applied asynchronously. Reading the position straight back after
set_position returns the value from the PREVIOUS show, which made the
verification added in the last commit report a MISMATCH on placement that was
in fact correct — the read-back was one show behind, not the placement.

Set the position again after show() and read back once it has settled. The
second call also runs after the window has adopted the scale factor of the
display it landed on, which is what LogicalPosition is interpreted against.

Add TT_TRAY_SELFTEST="x,y,w,h;…", which replays synthetic anchors through the
real placement path and logs computed vs settled for each. Placement depends
on the live monitor arrangement, so it cannot be unit-tested and otherwise
needs one click per display per attempt; this makes it a loop that runs
unattended. Verified on a 2x built-in plus a 1x external: 6/6 MATCH across
both displays, alternating between them, and both edge clamps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irror

A comment claimed Monitor::position() was in points while Monitor::size() was
in physical pixels. tao builds BOTH with from_logical(.., scale_factor()) —
see platform_impl/macos/monitor.rs — so each is points times that monitor's
own scale, exactly like the tray anchor rect. The code was already right;
only the stated reason was wrong, which is worse than no comment because the
next person reasons from it.

The measured desktop cannot tell the two readings apart: the 2x display sits
at x=0, where doubling is a no-op, and the 1x display's scale is 1. Add the
arrangement that can — a Retina to the RIGHT of a 1x primary, where its
2560pt origin is reported as 5120. Under the wrong reading every anchor on
it falls out of range and clamps to the wrong screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tray polled every 30s. The backend's session cache TTL is also exactly
30s, so every poll arrived as the cache expired, missed it, and forced a full
rescan of every session across every harness plus a history write. One scan
costs ~25-35 CPU-seconds on a ~1,200 session history, so the tray asked for
scans faster than a scan could finish and the backend never returned to idle.

Measured on that machine, backend CPU per 45s of wall clock: 56.7 CPU-seconds
with the tray running, and a clean 60s baseline with no tray reads 0. The tray
was the entire load — about 86% of a core, continuously, and the laptop ran
hot. With the new default the same window costs one scan at startup and then
nothing until the next interval.

- default interval 30s -> 300s; spend accumulates slowly and opening the panel
  always refreshes on the spot, so this only governs the idle case
- a 60s floor: Preferences offered 10s with nothing to say what it cost
- values under the floor fall back to the DEFAULT, not the floor. Every
  existing tray.json says 30, so raising the default alone would leave every
  install on the old value, and snapping to 60s would still rescan for a large
  part of every minute
- the interval-vs-TTL relationship is a compile-time assertion rather than a
  test: the failure mode is silent, nothing breaks, the machine just gets hot
- panel in-page refresh 30s -> 120s, and the Preferences hint now states the
  cost of a refresh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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