47 tests, the first under any Workspace path. These functions carry a panel's
identity now, so a regression in swapPanels is two agents exchanging names,
not a cosmetic glitch.
Writing them found one: setApp preserved config whenever appType was not null,
so changing a panel from chat to terminal handed the terminal the chat's
{agentName} to read as its own settings. The comment beside it already stated
the opposite intent. Not reachable through the UI today — the picker only
appears on an empty panel, so the only route out of an app is via null, which
does clear it — but setApp is exported and its signature permits the direct
swap. Now only a same-app set keeps the config.
Also pins two things as expectations rather than folklore: a move drops zoom
and fitContent (todo 5.3, to fail the day that is fixed), and a split
redistributes sibling sizes evenly (todo 5.5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server's uniqueness check is the one that matters, but its 409 currently never fires
(the constraint name is on err.cause, not in the DrizzleQueryError message), so a collision
came back as "Internal Server Error". The address book is already in hand here — checking
it first turns the common case into a sentence the human can act on.
Diagnosis of the server-side bug, with the patch, is in COMMS/agent-panels-split-2026-08-07.md;
that file belongs to another agent and is still uncommitted, so it is theirs to apply.
The panel remembers its agent's NAME in its own layout config, not the server row's panel
id: movePanel mints a fresh id on every drag, so an id-based lookup forgets the agent the
first time the dashboard is rearranged. The name travels with the panel contents; the row
is found by name and re-anchored to wherever the panel now is.
A named panel passes the row's sessionKey to useChat instead of letting the server mint a
throwaway uuid per connection. That is the whole of continuity: the same key comes back on
every load, resume-cursor replays the durable events under it, and the claude sidecar
resumes the same transcript from its write-through map even after the session was reaped.
Its cwd comes from the row too, because deliverToAgentPanel already runs an incoming
handoff there — otherwise the same agent would work in two directories depending on
whether the human or a peer spoke to it.
Only on real dashboards. The fixed screens keep anonymous chat panels exactly as before.
A panel can now hold an opaque config blob that the framework stores, moves and deletes
but never reads. It lives on the layout node for the same reason zoom does: the layout is
already persisted per panel and server-side, so a panel's configuration outlives the tab
and is deleted exactly when the panel is.
swapPanels and movePanel now exchange { appType, config } as one unit. They used to carry
only the app type, which would have silently reset a configured panel to defaults on a drag.
Apps read it through usePanelConfig(panelId); PanelSlot already passes panelId to every
registry app, so nothing else in the framework had to change.
A conversation is turn-based: your message, the work, the answer, repeat. The
moment you send the next message the tool calls and running commentary that
produced the last answer stop being what you are reading and start being what you
are scrolling past. So every turn but the live one collapses to three parts —
what you asked, one summary row, and what I concluded — with the summary naming
what you gave up ("5 tool calls · 2 messages · 1 failed") so you can tell whether
you want it back. Failures are counted on the summary rather than only inside,
because a red row you have to open to find is a red row you never find.
It is a pure derivation over the message list rather than state, which is what
makes a reload render identically to a live session: no wire format, no
persistence, no server change. Dividers and compaction seams split a fold instead
of disappearing into one, because "5 tool calls" hiding a /clear misreports what
happened to the conversation rather than to the work.
A turn that ends cleanly without saying anything gets a marker row. It happens
rarely and is disproportionately confusing — the composer re-enables and nothing
appears, which is indistinguishable from a turn that died. Deliberately a seam and
never prose: words in my voice that I did not write are a lie, and the next time it
happened you would not know which kind of row you were reading.
Fold-open state lives in the list, not the fold, because rows are virtualised and
state inside one would be thrown away when it scrolled past the overscan window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compaction was the one thing the harness does that emitted nothing at all while it
ran, and it can run for minutes — silence that reads as a hung turn, which costs a
server restart to discover it wasn't. The sidecar now reports both ends: the start
from the PreCompact hook, the finish from the compact_boundary message with the
token count, both durable so a reload or a reconnect still sees them.
Tool rows open themselves while they run and hold for five seconds after their
result, so the inputs are on screen at the moment the call is made rather than
after the fact. The clock lives outside React, keyed by tool call id: rows are
virtualised, so unmounting is not the call ending, and a fast call can render its
start and its result together — a row that only opens when it catches the pending
state never opens for exactly the quickest calls. A click outranks the clock for
as long as the row lives. A failure behaves identically and differs only in colour,
so it stays findable by scanning and nameable in conversation.
Shell logs move off the green-on-black pre onto the shared code surface, which is
the one block that had no copy button and the one you most often want to hand to
someone else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five Bash rows in a trace read `cd /home/…/platform && git status…`, `echo "=== server-side…`,
`echo "=== opencode handler…` — cut, every one of them, exactly where they started being useful. Two
things conspired. The summary showed the head of a compound command, which is usually scaffolding: a
`cd` into the repo, or an `echo` labelling output for a human. And both `slice()` and CSS `truncate`
drop the tail, which is where the identity lives — the filename that distinguishes ten Reads sharing
a directory, the target a command acts on.
So skip a leading `cd`/`echo` up to its `&&`, and pin the tail as its own non-shrinking span so the
head ellipsises and the cut lands in the middle at whatever width the panel is. Both are display
only: expanding the row, and the copy button, still give the command verbatim.
The right margin traded `done` for what the call found. Success was the loudest colour on the row
and reported the least interesting fact about it, once per row; a failure still earns its red. In its
place the count that used to cost an expand to learn — `no matches`, `12 lines`, `3 files`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
soulseek had a zoom control wired into its own panel header, persisted
under its own screens/ key. move it into the framework so every panel has
it, and drop the soulseek-specific copy (its header was then identical to
the default, so that goes too, along with the orphan db row).
the factor lives on the LayoutPanel node rather than in its own
useDashboardState key: the layout is already persisted per panel, and a
separate key would seed a server row per panel on mount. absent at 1, so
an untouched panel adds nothing to the stored layout.
uses css zoom, not transform: scale. a transform repaints at a different
size without re-laying out, so the panel keeps its 100% geometry and
anything anchored or percentage-sized lands wrong — chat's composer made
that obvious. zoom scales used lengths instead: children reflow, h-full
still resolves to the panel, and rem-based tailwind text scales with it.
@container moves onto the zoomed element so container queries respond to
the effective width, the way they would in a genuinely narrower panel.
zoomable: false opts out the terminals (xterm measures its own cell grid)
and remote desktop (novnc does its own scaling and pointer mapping).
fixes chat's virtualiser under zoom: it measured bubbles with
getBoundingClientRect (rendered px) but positions them with translateY
(layout px), so at 70% every bubble was placed too early and they stacked.
new helpers/measure-zoomed divides the element's currentCSSZoom back out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the cut-off notice is its own event now rather than an error: nothing is broken
and nothing is lost but the turn, so the row says what happened and offers the
one action that fixes it. the conversation is already durable — the claude
session id is written through to disk and passed back as resume: — so retry
just resends the prompt on a session the fresh agent picks up with full
context. read back out of the transcript, so a second window on the same
session can offer it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
restarting officer-agent takes every persistent session with it and nothing
downstream notices: the browser's socket is healthy, officer's subscription is
a bus filter, and there is simply never another event. the spinner ran forever
and a refresh didn't help, because the transcript has no ending to read.
keyed off the agent *registering*, not disconnecting — a disconnect fires on
every `pm2 restart officer`, when the turn is fine. a registration socket dies
with its process, so an agent appearing on it is a new one. covers the sitting
tab; the reconnect path covers the rest, with the client now sending its belief
that a turn is in flight and officer checking it against the agent over a new
claude:is-generating. the check fails toward alive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hover-revealed means most people never find it, and touch has no hover at all.
70% white on the dark block, brightening on hover; the pre reserves right
padding so a long first line scrolls up to the button instead of under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the bubble's copy button copies the whole reply, which is the wrong unit when
the reply is prose ending in one command to run. fenced blocks get their own
button; inline code doesn't. text read from textContent at click time rather
than the markdown ast, trailing newline stripped so a pasted command doesn't
run itself. the positioned wrapper takes the vertical margin, or the pre's own
margin collapses through it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pressing stop ended the turn with "Claude Code returned an error" — the agent
sdk reports interrupt() as an ordinary failed result, indistinguishable from a
real fault downstream. the sidecar now flags the session it interrupted and
rewrites that event to the existing durable 'stopped', which opencode already
emitted. escape stops the turn (bound to the chat subtree, not the document),
and the prompt comes back to the composer verbatim unless you've started typing
something else. history parity: claude files [Request interrupted by user] as a
user message, so the transcript reader maps those exact strings to the same
role instead of replaying them as something you typed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
useCapabilities is the frontend's view of the model and explicitly NOT its
enforcement — hiding a dock icon is a courtesy, the 403 in origin-validation
is the lock. so it fails OPEN: if the request errors the full dock renders.
a member clicking through to a 403 is a bad minute; an owner locked out of
their own platform by a transient network error is an incident, and the
server refuses what it should refuse either way.
the endpoint returns held routes AND denied routes, because absence from the
held list cannot distinguish a route this account lacks from one no
capability claims at all — `/`, the settings shell — and a guard that cannot
tell those apart either blanks the app or guards nothing. i wrote the first
version without the second list and it silently permitted everything.
`can` and `canVisit` are memoised on the query data. a verb rebuilt every
render gets a new identity every render, which is how every playback report
in the jellyfin player was disabled for days; the dock filter puts one in a
useMemo dependency list, so it would have been the same bug.
the permissions screen is one role at a time, with an explicit save and a
dirty state, rather than a roles-by-capabilities grid — a grid invites
reading across rows, which is not a question anyone has, and makes revoking
gitea for every member one click among fifty. it also states plainly why
terminal, chat, files and the rest are absent, so their absence reads as a
decision rather than as a missing feature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list collapses each chain to its newest link — the only one that can be
resumed — carrying the root's title and start time, the summed message count
and a part badge. The detail splices the chain's transcripts oldest-first with
a divider between parts, server-side, so the client's index-window pagination
needed no change.
The divider says "context cleared — nothing above this is in memory", because
the whole risk of merging is that the history reads as continuous when the
agent's context is not. Delete cascades the chain and the confirm says how many.
Supersedes the "continues X" line from the previous commit: there is nowhere to
link to once the parent is scrolled up above you.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/clear starts a new claude session and the list showed it as an unrelated
conversation. claude records no parent link anywhere — not in compactMetadata,
logicalParentUuid, summary.leafUuid, the per-session slug, or the live process
registry — so infer it: a cleared transcript opens with /clear, and /clear
happens inside one process, so the parent is the conversation in the same group
that was writing to disk at the instant this one began (4ms apart, measured).
matching is on per-minute activity rather than updatedAt, because resuming a
parent moves its end time past its child's birth and lost the link entirely for
two of the three cleared sessions here. the window is symmetric because clearing
makes claude summarise the conversation it is ending, so the parent's final
record can land after the child's first. ambiguity fails closed — the wrong
parent also renames the conversation.
read-only: nothing is written back to claude's store, and an explicit title
always wins.
also: cleared sessions were titled "<command-name>/clear</command-name>" because
claude does not set isMeta on slash commands; and the chat header was hardcoded
to undefined, so it read "New chat" above every conversation you opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.
the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.
loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.
the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.
also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.
walkthrough doc gains item 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read-aloud swallowed every error: the spinner stopped, the speaker icon came back, and a
TTS service that was simply down looked identical to a button that did nothing. Both the
synthesis failure and the playback failure now say so.
errorText moves to helpers — useClient rejects with a plain { status, message } object
rather than an Error, so the reflex instanceof check reports every API failure in the app
as "unknown error". Two callers now, and it is the wrong thing to reimplement per file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--duck-dark inverts to near-white in dark mode, so every place the chat used
it as a text or border colour was drawing light-on-light: the composer's own
text, the question prompts, the launcher textarea, the session detail bar, the
"send a message to start" placeholder. all of it moves to the semantic tokens,
along with the raw red-500s, which had no dark story at all.
the background task tray had seven labels below the 12px floor, including the
live log itself, and hardcoded green-600/red-600/amber-500 where the shared
tones exist. its detail panel was capped at a flat max-h-64 while the composer
it docks in is shrink-0 and the transcript above is flex-1 — so in a short
panel an open task could leave almost no conversation visible. capped against
the viewport too.
a failed background task drew the same CircleSlash as a stopped one: the two
outcomes you most need to tell apart were one glyph.
the attach menu offered four things and did two — "Text File" and "PDF" had no
onSelect at all. text files now inline into the composer as a fenced block
with the filename, size-capped and rejected if they turn out to be binary. PDF
is removed rather than faked: nothing in the platform extracts PDF text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the list drew its own border per row on top of nothing, so every boundary
between two rows was a double hairline, and it emphasised two things per row
where the design language allows one. DataRow/DataList settle both.
loading and empty were the same grey sentence, which made a slow transcript
read look like an account with no history; they are now LoadingBlock and
EmptyBlock, and a failed read gets an ErrorBlock with the actual message
instead of rendering as "no sessions".
rename and delete swallowed their failures whole — useClient only raises a
dialog for 401, 403 and 5xx, and the likely error here is a 404 from a
transcript that vanished under you. both toast now, as does a deep link to a
session that cannot be read, which used to open an empty pane and say nothing.
the New Chat button was duck-teal filled with duck-yellow text: duck-teal is
a bright cyan in dark mode and duck-yellow has no dark override, so the pair
sat near 2:1 contrast in both themes.
active-row highlight now comes from the route rather than the selection
channel, so it is right on a deep link before any panel has published, and
deleting the open session navigates out of it instead of leaving a dead route.
deletes SessionBar and SessionContextMenu: the first was exported through two
barrels and imported nowhere, the second was never imported at all and typed
its session id as a number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the most-looked-at panel in the chat was the worst rendered: an Edit showed
old_string and new_string as two flat monospace blobs, so the only question
you had — what changed — was the one thing you had to work out by eye.
adds a small LCS line diff rather than a dependency; jsdiff would ship to the
browser to do a textbook algorithm that is shorter than its own integration.
no line numbers, deliberately: Edit fragments carry no file position, and an
invented line number is worse than none.
the collapsed row now carries +n −m, so the size of an edit is legible without
expanding it, and copy yields the resulting text rather than a key: value dump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the provider label was bg-duck-dark/80 text-white, and --duck-dark is
near-white in dark mode, so it was white on white for every session that
had already started. same class on the active provider tab.
the streaming bubble bailed on empty text, so the wait between send and
the first token — tens of seconds with thinking on — rendered nothing at
all. it now shows the bubble with pulsing dots.
also: tool status tones onto tone.ts instead of a third copy of the same
green/red/amber ternary, sub-12px labels up to text-xs, break-all to
break-words so shell commands stop breaking mid-identifier, and
break-words on the user bubble so a pasted url stays inside the pane.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commits, branches, cross-repo issues, notifications and organizations all move
onto DataRow, which is what makes them agree with each other — five lists that
were each hand-assembled from the same flex/gap/truncate parts now genuinely
share one row.
The repo header gets the same owner-muted / name-semibold split as the list row,
so the two screens agree about what a repository is called. Its description goes
from 12px to 14px, and the tab strip with it — those are read, not scanned, and
they were the smallest text on the busiest screen.
DataRow gains `href` for destinations outside the app. Notifications needed it:
Gitea's subject URL can usually be parsed back into an in-app route and
sometimes cannot, and the row should stay clickable either way rather than
becoming a dead div on the payloads that do not parse.
Empty states across the dashboard now say what the search actually covers.
"No open issues" was hiding that Gitea's cross-repo search only ever looks at
issues you created, are assigned, or are mentioned in — which is the difference
between a quiet week and a misconfigured token.
Typechecks clean and prettier is clean. Still not rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gitea goes first because it is the first Officer surface anyone outside this
machine will see.
GiteaBits is now a thin adapter over components/Data rather than its own set of
styles: it translates Gitea's vocabulary into the five tones and gets out of the
way. Export names are unchanged so the ten call sites did not all have to move
at once.
The one judgement call worth flagging is CLOSED ISSUE -> neutral instead of a
colour of its own. Most issues in any list are closed, so giving them a tone
paints most of the screen and leaves the open ones no quieter than the rest;
neutral is what makes "open" findable, which is the only thing anyone scans an
issue list for. A closed PULL request keeps danger — that one was rejected, and
rejection is a real outcome. Merged is info, because a merged PR and an open one
were previously the same green.
Repositories and issues now build on DataRow. Repo rows put the owner prefix in
muted and only the name in medium, so one weight per row survives; issue rows
truncate rather than wrap so every row keeps one height and the list can be
scanned down its left edge. Timestamps are RelativeTime, so "2 months ago" now
carries the exact date as a hover title instead of losing it.
Swept the whole app for drift: emerald/amber/sky/red palette numbers to
success/warning/info/destructive, and every text-[10px] and text-[11px] to
text-xs. ring-black/5 and text-black/50 are gone — both were invisible in dark
mode. Nothing below 12px remains anywhere in Gitea.
Empty states say why they are empty now. "No open issues" on a repo with fifty
closed ones was technically true and useless.
Typechecks clean. Still not rendered in a browser — needs pm2 restart officer
and a hard refresh. text-xs is still 65 uses against 21 text-sm; the remaining
pass is per-case judgement about which of those are meta and which are content
that should be readable, and it is not a sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DuckSuite_Design_Language.md covers icons only — mascot geometry, lighting,
one-idea-per-icon, readable at 64px. It says nothing about type, density or
data, which is why the chrome looks considered and every data view does not.
This is the companion document plus the primitives that enforce it.
The diagnosis it is written against, from grepping two apps: 11 uses of
text-[10px], 7 of text-[11px], 52 of text-xs and 3 of text-base, with no rule
about which meant what; six radius values; hand-picked emerald/amber/red/purple
next to unused --success/--warning/--destructive tokens; text-black/50 and
ring-black/5, which are invisible in dark mode. None of that was a bad decision,
it was the absence of one thirty times over — so the fix is to remove the choice
rather than to have better taste.
docs/design-language-interface.md sets four type ranks with a 12px floor, one
focal point per row, five state tones, three radii and a spacing rhythm. The
principles are lifted from the icon language rather than invented, because
"one focal point, reads instantly, no unnecessary decorations, if it needs
explanation it is too complicated" is already the right rule for a dense list.
components/Data/ is how you spend that vocabulary: DataRow/DataList/RowMeta,
StatusPill/StatusIcon, LoadingBlock/ErrorBlock/EmptyBlock, RelativeTime. Rules
you have to remember are rules thirty views already broke, so the shape encodes
them — DataRow takes exactly one title and everything else is meta, RowMeta
puts separators between items so a trailing dot cannot appear, RelativeTime
carries the absolute timestamp as a hover title.
Adds --info (violet) as the fifth semantic tone, light and dark. "Merged" and
"in progress" are neither good news nor bad, and painting them with --success
makes a merged PR and an open one look like the same thing.
Entirely additive: nothing imports these yet, so no existing view changes and
there is no collision with the other agent working in this tree. Typechecks
clean. Not yet rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit's explanation of retargetUrls was inverted, in the message and in the comment.
It said /user and /repos build from ROOT_URL and /contents from the public host. It is the other
way round: this instance's app.ini has ROOT_URL = https://gitea.pastilhas.dev/, which is what
/contents stamps, while /user and /repos echo the REQUEST host — loopback, because Officer dials
http://localhost:9004 directly instead of going through the reverse proxy.
Behaviour is unchanged and was already right: rewrite only a private/loopback URL, only onto a
public target. Which half of the response is wrong does not affect that rule, which is why the bug
did not show up in testing. But a comment that names the wrong cause is how the next change
re-breaks it, so this corrects the record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.
Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.
Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.
retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.
Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.
Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.
/api/file-browser/video-info POST {url} -> GET ?url=
/api/file-browser/video-playlist POST {url} -> GET ?url=
/api/server-settings/ocr/models POST {url} -> GET ?url=
/api/transmission/_officer/port-test POST -> GET
/api/jellyfin/_config/:id/test POST|GET -> GET only
The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.
Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).
So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.
Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.
docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The policy editor now opens read-only behind an Edit button, and edit mode brings up an assistant:
describe the change, get a complete revised document back, read a line diff of it, and only then press
Save. Nobody writes an ACL from memory — it is HuJSON in Tailscale's grammar keyed to names only the
server knows — and that, not typing speed, is what made this screen unusable.
The model never touches Headscale. It proposes text, the text lands in the editor's draft, and the
existing Save button is still the only thing that leaves the browser. A model that could write the ACL
directly is one that can partition the network the owner is connected through.
The sidecar calls officer-anthropic-proxy on loopback for one request with a timeout — no session, no
agent, nothing persisted. It sends the draft on screen plus the tailnet's vocabulary (user, node and tag
names) and no credentials of any kind. It does not validate the reply either: Headscale owns the only
parser that counts, same argument as policy.ts.
editing one server then another without closing in between reused the same
mounted form, which seeds its fields at mount — so the second server showed
the first one's name, url and ssh host. submit diffs that stale state
against the new server prop, so saving would have written them to it.
policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.
nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
Reads the per-server Officer Companion (COMMS/HEADSCALE_COMPANION_API.md): container health with its
own diagnosis, a log snapshot, a live SSE tail, and restart/stop/start.
The companion sits at ${server.url}/officer-api behind the same admin key we already store, so there
is nothing new to register — but only the sidecar can decrypt that key, so the sidecar proxies it and
the browser never talks to the companion directly. That also rules out EventSource for the stream
(no Authorization header), which is why the tail is fetch() + a hand-rolled SSE reader.
Two inversions the code is built around: /health is always HTTP 200 and must be read by verdict, and
an absent companion is a state to render rather than an error — an HTML 502 is nginx, a JSON 502 is
the companion reporting a failed docker op, and the admin API on the same domain is independent
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.
Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.
The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).
The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.
Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chrome came back for a couple of seconds every few minutes during a film with
nobody touching anything. Only three things wake it, and none of them is periodic —
so the culprit is a stray pointermove. A mouse sitting on a desk still emits the odd
one-pixel event, a bumped table emits a few, and the browser synthesises a zero-delta
move of its own when the cursor style changes, which this player does every single
time it hides the cursor.
A move now has to travel eight manhattan pixels from wherever the pointer last
genuinely woke it. The anchor only advances on a real wake, so a slow deliberate
drift still accumulates past the threshold — it is jitter around a fixed point that
stops counting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The auto-hide timer was there and correct; `position` was in its dependency list.
That advances about four times a second while a video plays, so the effect re-ran
and cleared the pending timeout every ~250ms and the 2.5s never elapsed. The chrome
could only hide while playback was stopped — which is exactly when the code
deliberately keeps it up — so in practice it never hid at all.
Not a fullscreen bug, though fullscreen is where a permanent scrubber is
impossible to ignore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things about the background-task strip, all the same complaint: the chip you
want is the one you cannot see.
Newest first, because the strip scrolls horizontally and the far end is off-screen —
chronological order put every task you just started exactly where you had to scroll to
reach it. Finished chips are capped at five, so a long session stops turning the tray
into a history; running ones are never counted or dropped, since watching them is the
whole point. And the default horizontal scrollbar is ~15px tall and laid out inside the
row, which is what made the chips read as squeezed — it is thin now, and the row and
chips have the padding back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the gap limit was 1000, hardcoded, with the comment noting that raising it costs
node CPU and not correctness — and no way to raise it. it is the one thing about
a scan only the owner can know: how many addresses their old wallet handed out
and never had paid. RescanOptions threads from the POST body through the backend
and the source to utxos/scan, capped at 100k because past that the scan takes
longer than anyone waits. the card gets a "search depth" field beside the button,
blank meaning the default.
deleting a wallet with no seed took one unconfirmed request. the dialog asked for
the wallet's name and then threw the answer away, so the check existed only for
whoever went through the dialog — a node wallet still holds the credential, the
labels and the freezes. confirmName now travels with the request and the route
enforces it. a bodyless DELETE is told which field is missing rather than that
its JSON did not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
four wallet defects, none urgent, all cheap:
selectCoins let an explicit coin-control pick override a freeze. naming an
outpoint now overrides only the confirmed-only default; frozen is absolute.
balances counted frozen coins in onchainConfirmed, so Send showed a figure a
max-value spend could not reach. Balances gains onchainFrozen — a component of
confirmed, not a deduction — filled at the route layer, because freezing is
Officer policy in Postgres and no backend can see it. The route only reads utxos
when something is actually frozen. Send subtracts it under "Spendable";
Overview lists it beside unconfirmed.
a rescan in flight upstream was invisible after a sidecar restart, and a second
POST would have queued behind it (scantxoutset is single-threaded node-wide).
adoptRescan polls an existing NBXplorer scan instead of starting one, and the
GET route falls back to it when local state is gone.
the per-variant scan deadline counted queue time, so a variant that sat behind
another wallet's scan timed out without ever having run. the deadline now
refreshes while the status reads Queued.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the card keyed off `sync.rescan` being null, which is what esplora
reports — and equally what nbxplorer reports before its first scan. so
the guard could not tell "unsupported" from "not yet run" and resolved
it the wrong way: every esplora wallet got a button that 501s.
canRescan carries the distinction explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>