Skip to content

Faster traffic table and startup, hand-drawn charts, Rust shell clean-up, menu bar menu with Open first and a Today block - #293

Merged
fylorn merged 37 commits into
devfrom
perf/lite-optimizations
Oct 8, 2026
Merged

fylorn merged 37 commits into
devfrom
perf/lite-optimizations

Conversation

@fylorn

@fylorn fylorn commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

A performance and clean-up round across the app, plus a redesigned menu bar menu. No core change: still pinned to v0.64.1. Measurements are from a production build of the full preview harness in headless Chromium with about 2,000 history rows, unless noted.

Traffic table

  • One shared context menu and one "…" menu for the whole table instead of a Radix menu per row; tooltips in cells mount on hover/focus. Rows are windowed (flat and grouped), with column widths, sticky header, keyboard cursor and scroll-into-view kept exactly as before (cursor probes clean in Chromium and WKWebView).
  • Switching to Traffic: 1.1 s → 50 ms; JS heap 280 MB → 14 MB; DOM 66k → 2.1k elements; style/layout/paint under 2 req/s: 7.1 s → 0.76 s per 10 s.
  • The new-row flash and the in-flight dot's ring animate opacity/transform only (they used to repaint background-color/box-shadow every frame). Frame-by-frame identical in WebKit; on Windows the ring now draws at all (Chromium could not animate the old color-mix(currentcolor) shadow).

Shell, data flow, startup

  • Request rows live in an external store; only Traffic, the open palette, busy-key cells and the overview refresh tick subscribe, and pages are memoized. Dashboard JS under 2 req/s: ~1,050 → ~590 ms per 10 s.
  • While the window is hidden, events go straight into the (capped) store without publishing, and the live chart's 500 ms timer stops; heap growth for 100k hidden events 7.5 → 1.8 MB.
  • Reconcile asks History only from the earliest unmatched start (from_ms) and copies only changed rows: ~2,000 → 12–20 rows per reconcile.
  • Every page, the palette, the config/history/import dialogs, the calendar and the connection dialogs load on demand, preloaded after hand-over. JS before the launch screen paints: ~2.1 MB → 250 kB; launch screen 72 → 37 ms; warm reveal 160 → 127 ms.
  • Shell (975 lines) is split into src/shell/ (useCoreLink, Workspace, SourceList, Banners, Pages); the core state string is parsed in one place (src/coreState.ts, tested).

Charts

  • recharts is replaced by hand-written SVG (ui/charts.tsx): same monotone curves (path coordinates match to 0.001 px), ticks, gradients, tooltip and its edge flip. Pixel-identical in 14 of 15 before/after scenes; switching tokens ↔ cost now morphs instead of sweeping in again, which also fixes the stacking order staying stale after a switch. −337 kB of JS.
  • One ui/sparkline.tsx and one ui/meter.tsx replace three drifted sparklines and seven hand-rolled bars.

Clean-up and consistency

  • Config file / Version history dialogs load through useResource: a failed read shows an error with Retry instead of spinning forever.
  • Dead code, unused exports and 15 unused i18n keys removed; src/i18n.test.ts now fails on unused keys. date-fns dropped; dev-only lockfile bumps close the open Dependabot alerts (MCP SDK, proxy-addr, sharp, source-map-js).
  • AlertDialogConfirm with pending replaces 8 hand-built copies; shared helpers moved from keys/parts.tsx to src/ui/; one copyText; useLoginWait for the ChatGPT and Z.ai dialogs; model lists through one cache; RequestDrawer split into src/traffic/drawer/; plugin code↔form sync extracted into a tested EditingEngine.
  • One way to write each quantity: latency 1182ms, dates 10-08, tokens 1.5k (src/format.ts).

Rust shell

  • Dashboard, key usage and upstream stats query core in parallel over one pinned connection (remote: ~27 round trips → ~3); list_clients shares one HTTP client and one models request per key.
  • The login-shell environment read starts at the top of run() and overlaps setup; the event bridge and heartbeat wait for core instead of polling while it is down; settings and notice writes leave the main thread.
  • A failed tray, picker or main window at startup is logged and raised as a notice instead of taking down the app and core supervision.
  • Scan rules compiled once; duplicated tw-adopt helpers merged; tests moved to sibling tests.rs; menubar/macos.rs split into info.rs and a debug-only preview.rs. Tests can no longer reach the real data directory.

Menu bar menu (design approved by the owner)

  • Open ThinkWatch Lite is always the first row. A Today block shows today's tokens large, requests · failures · cost beneath, and a sparkline of tokens per local hour (from /summary/buckets/by, replacing the /summary call, so no extra request). One row per quota window actually reported, with its reset time. Then live requests, tools (group switch, copy address with the address shown, copy key), then update / settings / connection, then quit. Not-running states keep Open first, then the status and its actions. The tray follows the same order.

Checks: pnpm typecheck, pnpm test (929), pnpm build; cargo fmt --check, cargo clippy --all-targets -D warnings, cargo test (897); Windows/Linux cross-target clippy on the platform code touched; every page loaded in zh/en/empty/live with no console errors.

🤖 Generated with Claude Code

fylorn and others added 30 commits October 8, 2026 20:00
…t entry is used

Deletes button-group, progress, the mobile sidebar branch (the window is never
narrower than 820px), nav.tsx's revealSection, seven unused exports, the
Segmented re-export and the unused date-fns dependency. i18n.test.ts now uses
the type checker to fail on any messages() entry nothing reads; it found four
more dead entries besides the eleven from the audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@modelcontextprotocol/sdk 1.32.1 and proxy-addr 2.0.8 (through shadcn's MCP
server), sharp 0.35.5 (screenshot pipeline) and source-map-js 1.2.2 (through
vite, postcss and tailwind). Lockfile-only for the transitive ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… key read

Each control call opened a fresh connection, did a Noise handshake and
re-read config.yaml for the key. The dashboard made nine of them one
after another (about 27 round trips on a remote core). ControlClient::pin
now resolves the target and key once; the dashboard, key_usage and
upstream_stats send their queries concurrently through it. A pinned key
that went stale after a rotation still gets in: a wrong-key handshake
re-reads the config as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
list_clients fetched /v1/models six times one after another, building a
new HTTP client (and connection) each time, although the clients without
a key of their own all ask with the same default key. It now groups the
clients by key, shares one client and asks each key once, concurrently.
The model list is still fetched for clients that are not installed: the
manual-setup dialog of an absent client shows it.

Reading the clients' config files (ops::list, and the WSL listing that
goes through \\wsl.localhost) now runs on a blocking thread instead of
an async worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd retry

Both dialogs fetched by hand and only toasted on failure, so the reading line
spun forever. They now use useResource + Loadable: LoadingState for the editor,
a table skeleton for the history, ErrorState with Retry, and an outlined empty
state. Persistent notices in the editor are Banners (ui/alert.tsx is gone),
buttons use pending, the editor box uses the popover token, and the last
direct sonner imports go through notify. The plugin code fallback uses
LoadingState too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every local core start ran $SHELL -l -i first (up to 5 s) and waited
for it. The read now starts ahead of time: at the top of run() when the
startup connection is local (overlapping Tauri and plugin setup), when a
restart is requested (overlapping the old core's shutdown), and during a
crash backoff. load() takes that result if it was started within the
last 30 s and reads afresh otherwise, so each start still gets the
values of that moment, as promised for ${VAR} resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the local core was down the event bridge retried every second
forever, each time re-reading config.yaml and dialling the socket, and
the heartbeat woke every five seconds even when core was not running or
the app was on a remote connection. Both now wait on the supervisor's
state: the bridge subscribes once core answers its control plane
(running, or the safe-mode core that is up), the heartbeat starts
counting once core is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
atomic_file::write flushes to disk (about 6 ms per write on this Mac)
and was called from synchronous Tauri commands, which run on the main
thread: the notice list commands, the settings setters and the update
check toggle. They are now async and write on a blocking thread, as is
the mark-as-read that follows a click on a notification or on a notice
in the menu bar. prefs::update now serialises its read-modify-write so
that two settings changed at the same moment both stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s pending

AlertDialogAction now has a pending prop like Button, and keys/parts'
ConfirmAction becomes AlertDialogConfirm in ui/alert-dialog, replacing the
eight hand-built "action + busy + spinner" copies. focusSelf/useDialogFocus,
CopyButton/CopyIconButton, Tile, openable/OPENABLE_ROW/stop, DISCLOSURE and
RowsSkeleton move from keys/parts into src/ui (the sparkline stays put). The
two copyText helpers become one in ui/notify; traffic/cells re-exports it so
the traffic files keep their imports. README documents the pieces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
menubar::install, show_picker and show_main_window returned their
errors with ? from setup, so a tray builder failure (possible on
Windows and Linux) or a window that could not be created ended at
.expect("Tauri 起不来") and took core supervision and the gateway down
with it. Setup now logs the error, raises a notice (so a system
notification still says it when no window exists) and keeps running,
the same way a missing core binary is handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scan() rebuilt the built-in rule set (compiling every regex) on each
MCP-page scan; compiling them took about 20 ms in a debug build against
0.1 ms for a scan of a small home. The rules are now built once and
shared with the file watcher. scan_clients and the MCP page commands
(targets, plan, apply) read and write the clients' config files on a
blocking thread instead of inside the async command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both dialogs carried the same alive/settled refs, settle guard, login_finished
subscription and polling fallback. waitForLogin (a plain function, tested with
fake timers) and its useLoginWait hook now hold that logic once: event first,
status fetched for the account, polling as fallback, settle once, nothing after
stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They fetched every upstream's model list directly, so the lists the upstreams
page and pinned models share were neither reused nor refreshed. useResources
(lib/resource) reads several entries of the same cache at once; upstreams/data
gets modelsKey, useModelLists and enabledModels, and every reader and
invalidation uses the one key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1346-line RequestDrawer.tsx becomes one file per tab (Timeline, Routing,
Usage, Payload, Replay), the shell (RequestDrawer.tsx: sheet, header,
skeleton) and the shared rows, stats, state and cost text (parts.tsx). Its
text table and test move along; Conversation takes BodyText from Payload,
the drawer's autofocus uses focusSelf. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tw-adopt: one unique() for the gateway's model list (qwen, grok, pi,
opencode and models_stale each deduplicated it themselves), one NO_KEY,
Gateway::v1 used by qwen, grok, hermes and opencode instead of four
format! copies, plan::lookup_str in place of the identical field() in
qwen and grok, and qwen/grok models_in reading their specs back with
pi's count_of/flag_of like opencode already does. One crate-level
now_ms() and wide() replace the copies in plan, foreign, detect and wsl.

App crate: one wide() in lib.rs for dialog, private_dir, single, theme
and the portable test; scan::now_ms goes in favour of notices::now_ms.
user_env keeps its own wide(), it has to stay self-contained, and the
confirm dialog's wide() is different (it replaces NUL with a space).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Latency was 1182ms in the table, 1,182ms in the drawer, 1,182 ms on the
upstreams page and 1.18s on Overview; dates 10-08 and 10/8; tokens 1.5k, 2k
and 200K. format.ts now has one helper per quantity: ms (1182ms, used by the
drawer, replay, upstream/proxy/connection tests, speed test and dry run),
msShort only for Overview's latency ranking, when/whenMinute/monthDay (also
for Overview's axis labels and the model-list timestamps that had their own
clock()), compact for all token counts including context windows, and span
for a session's length. traffic/format.ts and contextWindow are gone. README
lists the helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test modules of supervisor/mod.rs, clients/ops.rs and
menubar/model.rs move to sibling tests.rs files, as notices/ does
(multi-line string literals are kept byte for byte). menubar/macos.rs
loses the self-drawn rows (Info, InfoView, column widths, drawing and
their tests) to macos/info.rs, and the helpers only the menubar_*
examples use (offscreen preview, PNG writing, opening, closing and
dumping the menu) to macos/preview.rs, compiled only with
debug_assertions like memcheck. The examples fall back to an empty
main in builds without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The real ~/.thinkwatch/backups on a dev Mac had collected thousands of
folders from older tests. No current test writes there (the whole suite
run under a throwaway HOME leaves nothing behind), and data_dir() in the
app crate and tw-adopt's backup_root() now assert under cfg(test) that
THINKWATCH_HOME points somewhere else, so a future test that forgets its
temporary directory fails instead of writing into the user's data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useEditing held the two-way sync (debounces, sequence and form-generation
guards, pristine revert, flush before save) in refs and callbacks with no
tests. EditingEngine (plugins/editing.ts) is the same logic as a plain class
with injected core calls; useEditing only wires it to React through
useSyncExternalStore. editing.test.ts drives it with fake timers and a
hand-released fake core through every guard. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as remove, raw button

The AWS profile field label moves into the text table (the glossary keeps it
in English in both languages), the spinner's screen-reader label follows the
UI language, removing a header row or a price-sheet override is a written
action like the other list editors (x only closes), and the "show all"
toggle under unpriced models is a kit Button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
recharts and its dependencies (d3, immer, Redux Toolkit, es-toolkit,
decimal.js-light, reselect) cost 331 kB to draw one chart and dominated
the live range's 500 ms tick. StackedArea now draws the same thing
itself: monotone-X cubic curves (the algorithm d3's curveMonotoneX
uses; paths match recharts' output to 0.001 px), stacked gradient
areas, the half/top ticks with the same label placement, the dashed
grid, the hover cursor, active dot and live-edge dot, and the tooltip
with recharts' placement and edge-flip rules and its 120 ms glide.
Screenshots of every range, theme, language, hover position, the live
range and the empty state are pixel-identical to the recharts version.

Intentional differences: the tooltip appears where it belongs instead
of flying in from the chart's top-left corner, and its glide honours
reduced motion; the chart is no longer a focusable element that did
nothing with the keyboard; switching between tokens and cost morphs
the shapes over 250 ms, as the code always described. recharts swept
the chart in from the left again there, and kept the layers in their
old stacking order, so the model on top could end up drawn in the
middle until the page was reopened. The shadcn chart container goes
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three hand-written sparklines had drifted apart: the keys one had
no minimum peak (one request filled the bar), upstreams drew failures
on top, the traffic header drew them at the bottom in grey. They now
share src/ui/sparkline.tsx: each row scales to its own peak but never
below 4, a bar with requests is at least 3px, failures sit at the
bottom in red, bars use --chart-2 with --chart-1 for the current slot,
and empty slots keep the faint baseline. Per-bar tooltips (traffic
header) make each slot a 4px hover cell; without them bars are 2px
with 1px gaps as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven places drew their own bar (the overview's Meter, latency spreads,
speed bars, the session waterfall, retention usage, the quota bar and
the update download), with three different track greys, two grey fill
strengths and tiny values that could vanish. They now share
src/ui/meter.tsx: one track (foreground at 8%), sizes sm/md/lg (4, 6,
8px), a data-viz colour or a state tone (neutral grey, strong, warn,
error), an optional range start and a start/end mark for medians, a
2px minimum for any non-zero value, and meter/progressbar semantics
only when a label is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `core-state` string was taken apart in five places with slightly
different checks ("running:" vs "running", "safe_mode" with and without
the colon). parseCoreState() turns it into a tagged union once, with
isRunning() and controlUp() for the two questions callers actually ask;
anything unrecognised is "unknown" and treated as not running.

trouble(), launchPhase(), the upstream in-flight counter and the routing
flights now go through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live range (the overview default) re-rendered every 500 ms forever,
including while minimized or covered. The step timer now stops on
visibilitychange to hidden; events are still recorded, samples outside
the window are dropped as they arrive instead of piling up, and on
return the curve is re-laid at the current time before the timer
resumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The date picker (react-day-picker + date-fns, ~70 kB) is now loaded when
the custom-range button is hovered or focused, with a same-size
placeholder in the popover. ConnectionProvider sits in the first chunk
the main window loads, so its two dialogs and the error toast's
translation table are imported when needed instead of up front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live request rows move out of React state into a per-connection store
read through useSyncExternalStore (useRequestRows, useRequestsView,
useSessionViews, useSettled, useBusyKeys). Only the traffic page, the
open command palette, the busy-key cells and the overview/security
refetch tick subscribe, so a streaming request no longer re-renders the
shell and the current page every frame. Pages that take shell data are
memoized and get stable callbacks.

While the window is hidden, events are applied to the store as they
arrive (still capped at 2,000 rows) instead of queuing behind a
requestAnimationFrame that never fires; the list is published and the
settle/reconcile runs once when the window is shown again.

Reconcile after requests land asks History only from the earliest start
among the rows waiting to be matched (from_ms) instead of re-reading the
newest 2,000 rows every 2.5 s, and falls back to a full read when the
event stream cannot be trusted (dropped events, core stopped, an ending
for a row we never saw). mergeHistory builds a patch of changed fields
and copies only rows that changed. Session summaries reload on the same
settle tick, replacing traffic/useSessions.

App.tsx is now the small first chunk: connection layer, useCoreLink
(core state, status/overview retry loop, launch handover) and the launch
screen, which no longer pulls in tailwind-merge, the button kit or the
core translation table. The rest of the shell is shell/Workspace.tsx
(toolbar, shortcuts, overlays), with SourceList, Banners and Pages split
out; every page, the command palette and the config/import dialogs are
separate chunks loaded through usePart (no Suspense, so nested loads do
not hit React's 300 ms reveal throttle) and preloaded once the window has
handed over. describeCore/surfaceOf live in shell/describe.ts with tests.

JS needed before the launch screen paints: ~2.1 MB -> 250 kB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fylorn and others added 7 commits October 8, 2026 21:13
…zations

# Conflicts:
#	src/connection/ConnectionProvider.tsx
#	src/upstreams/data.ts
… only

The new-row entrance animated the row's background-color and the live
status dot animated box-shadow: both repaint every frame, so under steady
traffic the traffic table was repainting continuously. The flash is now an
overlay (::after on each cell of a table row, on the element itself
elsewhere) whose opacity holds until 45% and then fades; the pulse is a
same-coloured ::after disc that scales to 5px outside the dot while fading
from 0.55. Same keyframe stops, timing and colour; reduced motion still
turns both off. In WebKit the computed ring radius/alpha and the flash
alpha match the old values at every sampled time. Chromium (WebView2) never
interpolated the old color-mix(currentcolor) box-shadow, so the pulse ring
now shows there as it always did on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- One context menu and one "…" dropdown for the whole table (TableMenus)
  instead of a pair per row; items are built for the clicked row when the
  menu opens. Items, positions, keyboard (Enter/Space/ArrowDown on "…",
  Tab to the selected row's "…") and focus return are unchanged.
  RowContextMenuContent / RowDropdownMenuContent are split out of
  ui/row-menu so both paths render the same entries.
- Tip gets a `lazy` mode that mounts the Radix tooltip on first hover; the
  table's cells use it.
- Only the visible stretch of rows (plus overscan) is rendered, in both the
  flat and the grouped view; spacer rows keep the scroll height. Heights
  are measured after each render, WebKit's missing scroll anchoring is
  compensated, and keyboard reveal scrolls to rows that are not rendered
  yet. Column widths stay exactly as with every row rendered: the widest
  few cells of each column (estimated by text width) are rendered,
  invisible and zero-height, inside the header cells.
- Rows of a just-expanded group fade in once, not again when scrolled into
  view.
- The latency tooltip writes milliseconds without thousands separators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zations

# Conflicts:
#	src/traffic/SessionRow.tsx
…nts point at the split shell

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…row per quota window

The menu now opens with "Open ThinkWatch Lite" in every state, instead of
burying it under the stats.

While the gateway runs, the old status header, the Today section and the
quota section become one dashboard: "Today" with the gateway state (server
name for a remote core, generation speed) on top, today's tokens as the hero
number, then requests, failures and cost in a smaller line (the same
tokens-over-cost order as the menu bar), and a sparkline of today's tokens
per local hour on the right. Each reported quota window gets its own row
with a full-width bar. Copy Gateway Address shows the address it copies.
"Install Version" leads the app group when an update is pending; "Check
for Updates" comes last otherwise. When the gateway is not running, a
status block (dot, state, reason that wraps to two lines) replaces the
header.

Today's totals and the hourly series come from one control-plane call,
/summary/buckets/by with one-hour buckets from local midnight, instead of
/summary: same window and filter, so the sums are the summary's numbers
and the hero number always equals the sum of the bars. No extra request,
no polling; the current hour follows the clock while the menu is open.

The tray on Windows and Linux follows the same order as text items; the
Linux-only "move Open to the top" step is gone because the model already
puts it first. The preview exporter renders busy, idle, alert, remote,
stopped, unlinked, starting and safe mode in Chinese and English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 4f3ad28 into dev Oct 8, 2026
4 checks passed
@fylorn
fylorn deleted the perf/lite-optimizations branch October 8, 2026 14:29
@fylorn fylorn mentioned this pull request Oct 8, 2026
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