Repository navigation
Conversation
**The window could not be moved at all.** The title bar is `Overlay`, so there is no real title bar to grab — the whole window relies on `data-tauri-drag-region`, and that attribute works by calling a command gated on `core:window:allow-start-dragging`. That permission is not in the `core:window` default set, so it has to be listed, and it was not. Nothing failed and nothing logged; the drag regions were simply inert. The `description` on this capability file already warned that a missing Tauri v2 permission fails silently. It now also names this instance, because the general warning did not stop the specific case. ## The sidebar It read as a web admin panel, and the reason was not the palette. A macOS source list has six identifying features and this had none of them: no icons, a flat opaque background, a neutral-grey selection instead of the accent colour, heavy group headings, 172px of width, and rows tighter than the system's while the gaps between groups were much looser — the opposite rhythm to the platform's. **Icons, and they carry the collapsed state.** Nine 16px stroke glyphs, hand-drawn as inline SVG rather than pulling in an icon library: this codebase already hand-writes its charts and its PNG encoder for the same reason. Routing, Gateway and Clients get three different skeletons — a forking arrow, a two-tier rack, a laptop — because those three are the concepts most easily confused, and with labels hidden a mistaken glyph costs a wrong click. **The rail collapses to 60px**, which hands 136px back to the content. For an app left open watching charts, that is the only change here that improves the main surface rather than the chrome. Labels move into tooltips, so collapsing defers information rather than discarding it. The choice is remembered, read in the state initialiser rather than an effect so the first frame is already the right width. ⌘⌥S toggles it — the same key Finder and Mail use, matched on `e.code` because ⌥ turns `s` into `ß` on macOS. **Group headings are gone, replaced by hairlines.** They were eating roughly a third of the list's height to say something a rule also says. The cost is real and worth stating: a line can say "these two are not those two", it cannot say "this group is called Security" — and that group was deliberately split out of Monitoring. **A palette for the window chrome**, separate from Tailwind's neutrals. A pure grey sidebar is exactly what a web sidebar looks like; the system's own is always slightly cool because it is translucent over the desktop. These neutrals carry a blue bias — chosen, not inherited. **The selection follows the system accent colour** via CSS `AccentColor` with a hard-coded blue behind it. Someone who sets their accent to pink should see a pink selection; writing the blue in is the moment it turns back into a web page. ## Also `vitest` was collecting the test files inside `.claude/worktrees/`, a second checkout of this same repository. The suite reported 8 files and 68 tests where the repository has 4 and 38. Running them twice is not the problem — **running a stale copy is**: those files stop at the day the worktree was made, so they can hide a failure in the real ones or report one that was fixed months ago. Excluded explicitly. `*.tsbuildinfo` is now ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things from looking at it running. **The status dot was shouting.** Expanded, it sits next to the words "运行中" and reads as punctuation. Collapsed, it becomes the only colour in an otherwise empty 60px column — a fully saturated green dot making a loud announcement that nothing is wrong. Healthy now renders at 45% opacity and 6px instead of 8px: still visible, but you have to look for it. Amber and red stay at full strength, because those are the ones that have earned a glance. **Rows go 26px to 28px**, which is the system metric. 26 came from the compact variant of the sidebar sketches, but that variant had no icons — with icons the row has nothing in the spare vertical space and the whole list reads as undersized in a tall window. The empty area below the last item is left alone. Ten items in a tall window leaves a gap, and every macOS source list has one; the only way to close it would be to invent something to put there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`configVersion` is the optimistic-concurrency token every write carries, and it was only ever set by the `config_reloaded` event. That event reports a *change*; on a fresh launch where nobody has touched the config it never arrives, so the token stayed null and all six config-writing entry points were dead — the guard page's three-state controls, the listen mode, the upstream fields, the policy groups. The app looked read-only, and the error said "haven't read the config version yet, try again in a moment", which would never come true. Read the current version once on mount; the event stream keeps it current from there, and wins if it gets there first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two control-plane endpoints the desktop side had no way to reach. `new_key` generates a gateway key in core rather than in the UI: the alphabet and the length are security decisions, and keeping a second copy here guarantees that one day only one of them gets changed. `interfaces` is asked fresh every time, never cached. Plugging in a cable, joining another network or bringing up a VPN changes the list, and a cached one only puts an address in the picker that no longer exists — the consequence of choosing it is a gateway that will not start. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model the UI presented did not match the one core implements, and three of its four nouns had no page at all. **Keys.** The only access control there is — port decides who can knock, key decides who gets in, and without a key even 127.0.0.1 is refused. It used to be one read-only line inside "listen and access": you could not create, rotate, revoke or bind one. Everything else depends on it. With every client sharing one key, `RequestFacts.client` is always the same value, so traffic and sessions cannot tell them apart, per-client concurrency limits do nothing, and binding a route to a key is not expressible. The model-allow control is three-state, and the third state is "none at all" — an empty list. That is not a broken state, it is how you park a client, so it has to be reachable on purpose rather than by deleting the last entry. Every transition between the three works in both directions. **Routes.** A key walks exactly one route. Unbound keys walk the default route — the one you land on, not one everybody passes through. So two keys share a rule set by naming the same route, not by copying rules. **Proxies.** Previously a reference with no way to create the thing being referenced: the upstream table had a proxy column whose options came from a list of names, and nothing in the app could add to it. The password is write-only here — the view goes into logs, diagnostic bundles and the screenshots users post, so it can be set and cleared but never read back. **Listen mode.** Which interface the gateway binds to, which is not an allow-list: loopback, one LAN interface, or all of them. Interfaces are enumerated live, and an address in the config that no longer exists is still listed — dropping it would silently show a different address and look like the setting changed by itself. Switching rebinds the way nginx reloads, so there is no restart button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t one Adoption never passed `key_name`, so core fell through to `cfg.clients.first()` and five adopted clients all received the same key. The consequences chain: `RequestFacts.client` is then always the same value, so a route bound to a key never matches, per-client concurrency limits do nothing, and traffic and sessions cannot say who sent what. The whole per-client capability was defeated by one argument missing at the very end of the chain. Create the key first when there is no key of that name, then adopt. Two writes, and the moment in between is only "one more key that nobody uses yet" — harmless, unlike the reverse order, where the client is configured with a key that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`clear` reaches Rust as a `tw_api::PatchOp`, and that variant only exists in core as of #16 — with the lock behind it, TypeScript compiled, Rust compiled (nothing names the new variant), and the button failed the moment a user pressed it. Deserialising the UI's own JSON in a test makes a stale lock a compile-time fact rather than a runtime one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The model the UI presented did not match the one core implements, and three of its four nouns had no page at all. This builds them, plus the two fixes underneath that made every write path dead.
Also carries the collapsible icon rail and the draggable window from earlier in the branch.
The model
A key walks exactly one route. Unbound keys walk the default route — the one you land on when nothing else applies, not one everybody passes through. Two keys share a rule set by naming the same route, not by copying rules between them.
Keys
The only access control there is: the port decides who can knock, the key decides who gets in, and without a key even 127.0.0.1 is refused. It used to be one read-only line inside "listen and access" — you could not create, rotate, revoke or bind one.
Everything else depended on it. With every client sharing one key,
RequestFacts.clientis always the same value, so traffic and sessions cannot tell clients apart, per-client concurrency limits do nothing, and binding a route to a key is not expressible.The model-allow control is three-state, and the third state is "none at all" — an empty list. That is not a broken state, it is how you park a client, so it is reachable on purpose rather than by deleting the last entry. Every transition between the three works in both directions.
Routes
Create a route, name it, give it rules, bind keys to it. Deleting one that keys are bound to says which keys, and unbinds them in the same write — the other order leaves a moment where a key points at a route that is gone.
Proxies
Previously a reference with no way to create the referent: the upstream table had a proxy column whose options came from a list of names, and nothing in the app could add to it. Anyone who had not hand-edited
config.yamlsaw a dropdown they could only read.The password is write-only. This view goes into logs, diagnostic bundles and the screenshots users post — same discipline as upstream keys.
Listen mode
Which interface the gateway binds to. That is not an allow-list: loopback, one LAN interface, or all of them. Interfaces are enumerated live and never cached — plugging in a cable or bringing up a VPN changes the list, and a cached one only offers an address that no longer exists. An address in the config that is not currently enumerable is still listed, since dropping it would silently show a different one and look like the setting changed by itself. Switching rebinds the way nginx reloads, so there is no restart button.
The two underneath
The config version was never read. It is the token every write carries, and only the
config_reloadedevent set it. That event reports a change, so on a fresh launch it never arrives — all six config-writing entry points were dead, and the error said "try again in a moment", which would never come true.Adoption shared one key. It never passed
key_name, so core fell through tocfg.clients.first()and every adopted client got the same key. One missing argument at the end of the chain defeated the whole per-client capability above.Checks
cargo fmt,cargo clippy -D warnings,cargo test(63),pnpm typecheck,pnpm test(38),pnpm build— all clean.Every write path was walked end to end against a running gateway starting from a config with nothing but clients and providers. 19 of them round-trip, and the resulting file differs from the original only in the lines the run meant to change: comments, blank lines and ordering all survive. The six patch-layer and engine bugs that run surfaced are fixed in ThinkWatchProject/ThinkWatch-Core#16.
🤖 Generated with Claude Code