/plans/:name, no redirect guard: the bare route is 'no plan open', which is a
real state, so the auto-select-first effect is deleted rather than turned into
a Navigate. The picker stays a native select — chrome for one document, not a
master list — but it navigates instead of setting state.
Reading the server route for this turned up a path traversal: hono
percent-decodes params, so GET /api/plans/..%2F..%2Fsecret reached
join(plansDir, '../../secret.md'). basename() the param.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
audit m5. the active file is `?file=`, tree file rows and tabs are links, and a
`?file=` naming something that is not open now opens it — which is the part that makes
a pasted link actually work rather than just describe.
the open-tab *set* stays local state and i want that on the record as a choice, not an
omission. it is a working session, not an address: it grows without bound, every entry
costs a read on load, and nobody has ever linked someone else to a tab bar.
opt-in via a prop from the screen rather than the workspace identity the file browser
uses, because /code-editor renders CodeEditorView directly inside a Widget instead of
through the panel wrapper — there is no workspace to ask. a dashboard editor is
unchanged.
tree *folder* rows stay buttons, and unlike the file browser's folders this needs
nobody's call: expanding a directory is disclosure, not navigation.
two things fixed while in here. the tab close control was a role="button" span nested
inside the tab's own button — invalid before, and a nested interactive inside an anchor
after — so it is a sibling button with an aria-label now. and closeFile picked the
next-active file inside a setFiles updater, which is the impurity react double-invokes
in development to catch.
a path that fails to read is remembered, so a broken link errors once instead of once
per render, and the address is left alone rather than rewritten.
task-logs was a clean move — the detail fetch already keyed off the id, so only
its source changed. activity needed one decision: its two row kinds stream
through different query params, so the url carries the id and the screen derives
task= or path= from the registry row. the sse effect now depends on that derived
string rather than a fresh object, so the 3s poll cannot re-open the stream. an
id that has left the registry says so instead of waiting for output forever.
/activity also had no page-title rule and read 'Officer'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/tasks, /skills and /processes are one component, so one route pair each and the
rows become links. drops the auto-select-items[0] effect: the bare route is the
list with nothing open, which is a real state. editing and the just-created flag
move to ?edit=1 / ?new=1 — a link row cannot reset them on the way out, and
deriving them means navigating to another item clears them for free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/music?path=<rel> replaces the music:cwd channel. Each panel reads the param
itself through useMusicCwd(), so MusicBrowser, MusicDetail and FavoritesView
no longer tell each other where they are, and every drill-in is a <Link>:
library rows, folder rows, album/artist cards, both "up" affordances, the
favorites rows, and the dock's now-playing tile. Track rows stay buttons —
they play, which is a mutation.
A query param rather than a nested route because the location is only one of
the things this screen holds (the lyrics split and the favorites view are the
others), and a splat has to be a route's last segment.
MusicPlayerHost is mounted outside <Routes> and used to write the channel and
then navigate('/music') to make the write visible — the audit's only
navigate-with-a-side-effect. That collapses to one <Link>.
music:resync (a refresh signal) and music:favorites (a view of one panel) stay
channels, deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section is /soulseek/:section — nav entries are NavLinks, the view panel reads the same
URL instead of being told, and the dashboard's tiles and recent searches are real links (a
recent search now opens that search, not the search screen's front page).
The peer went in `?user=<name>` rather than the /soulseek/users/:name the audit sketched: a
second path segment would need a nested route just to keep the nav highlight, and `?search=`
had already set the convention there. That deletes the `soulseek:user` channel and with it a
`{username, nonce}` request the Users panel consumed-once and cleared — the nonce existed so
asking for the same peer twice counted twice. A link is idempotent, so there is nothing to
consume and nothing to disambiguate.
`soulseek:refresh` stays: it is a signal, which is what channels are for.
/system-monitor/:scope, the same shape as /photos: route pair, one Navigate guard after the
hooks, scope list as react-router NavLinks, and both panels reading useParams instead of
agreeing over a `monitor:scope` channel. The Dock's isActive is a startsWith, so its
highlight survives the redirect off the bare route.
Five settings pages moved from a `*_SELECTED` global to `/settings/:page/:section`. The
sidebar entry is a react-router `<NavLink>` rather than a button holding the key in its
onClick closure, so a section is linkable, cmd-clickable and gets its active state from the
router; each page renders one `SettingsRoute` guard that canonicalises both the bare route
and a section that does not exist.
Integrations needed more than the shared factory. It builds its own sidebar, and it kept the
Enterprise/Personal tab in a second global — which is why a deep link to a Personal section
could never have worked: the link set the section, the tab stayed on Enterprise, and the
content pane said "Select a section" about a section that existed. The tab is derived from
the section key now.
Also removes the `/settings/resources` menu item (audit M8) and its two locale keys: there
has never been such a route, so it bounced to the catch-all and out to `/`.
Four channels were bare string literals repeated across files, with the payload type supplied by each
caller. Neither hole errors: a typo yields a different, empty channel — publisher publishing into nowhere,
subscriber waiting forever — and a publisher and subscriber can simply disagree about the payload with
nothing to check them. defineChannel(name, initial) returns the hook, officerdev/src/channels.ts declares
the four, and every usePanelChannel call site in the repo now passes a shared constant.
files:refresh-signal was bumped two different ways: Date.now() at the Chat sites, setSignal((n) => n + 1)
at the FileViewer ones. The increment is wrong — useGlobal's functional setter applies against the value
captured at render, so two bumps in one render window both compute snapshot + 1 and the second writes the
same number as the first. Nobody re-reads and the file that was just written stays stale. Date.now() has
the same flaw at millisecond scale, and the four FileViewer sites (save, delete, extract, transcribe) sit
close enough to hit it. useFilesRefresh's bump is a module counter that never reads React state, so it is
right however many times it is called between renders, and it is identity-stable through a ref because
useGlobal's setter is a fresh closure every render and this goes into dependency lists.
system-settings:run-command is deleted. It had a writer once — 7c0b11c wired the AI harness installer to
it — and when that install moved server-side to POST /server-settings/chat-providers/install the write
went with it, leaving a channel whose only remaining writes were clears, a terminal pane nothing could
open, and a second layout nothing could select.
PanelComponentEntry's component, header and provider are typed with { panelId: string }, which is what
PanelSlot has always rendered them with. A no-prop component is still assignable, so no screen changed.
chat:active-session and preview:refresh are declared but still have no subscriber. preview:refresh has no
plausible one — the PreviewProvider that read it is gone from the repo — but both are published by the
chat panel, and that is not this branch's to change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`<AppRegistry />` and `<WidgetRegistry />` seeded through `useGlobal`'s `initialData`, which is not a
write: it applies only to whichever component reads the slot first. They worked entirely by sitting
above `<App />` in frontend.tsx — any WorkspaceView that rendered first would have created the slot as
`{}`, with no second chance, and drawn every panel on that screen as an empty box.
Both are now plain functions taking the QueryClient, called before createRoot().render(). They take the
client rather than running as a module-scope side effect because the app list imports every panel app
and every panel app imports the Workspace framework; keeping the call in frontend.tsx, the one module
that is nobody's dependency, is what stops that being an import cycle. Making useAppRegistry default to
the static list was the obvious fix and is exactly that cycle.
registerApp and registerWidget go with the components. Nothing ever called either, and a registry that
can be added to at runtime is a registry whose contents depend on what has mounted so far.
Separately: officerdev/file-viewer was a registration for a provider fed by a `file-viewer:<panelId>`
channel that nothing writes, with availableOnPanel: false so it could not be picked either. The file
viewer users actually see is an ephemeral panel from useFileViewerPanels, which supplies the body and
header itself and reads the path from the URL. No stored layout referenced the key — zero rows across
dashboards, screens, dashboard_defaults, user_state and user_settings — so the meta and its wrapper are
deleted rather than repaired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The allow-list existed on fourteen screens and was missing from every other one, which the previous
commit turned from fifty lines into one. These four are locked — the user cannot change what is in
the panel — so an appType that stops resolving strands them on the empty teal box in PanelSlot with
no picker and no way back.
Checked against what is actually persisted rather than against the defaults: `screens/desktop` holds
`officerdev/desktop` and `screens/files` holds `officerdev/file-browser`, both already inside the
list they are now being given. `screens/terminal` and `screens/dashboards` have no row at all — those
screens have never been opened on this machine — so they seed from the default, which also matches.
Nothing is rewritten by this.
Browser and Email stay unguarded on purpose. Their panels resolve through `components`, which
PanelSlot keys on the *panel id*, and their layouts carry `appType: null` — the app type is never
consulted, so pinning it would pin nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every locked screen shipped the same recursive normaliser: an ALLOWED_APP_TYPES set, a
normalizeLayout, a useMemo to apply it before the wrong panel could render, and a useEffect to
persist the fix. Fourteen copies, character-for-character identical except the two names — so a
fifteenth screen was a copy-paste, and a bug in the shape was a bug in fourteen places.
It is now `<WorkspaceView appTypes={{ allowed, fallback }} />`. WorkspaceView normalises before it
renders and persists the diff itself, which is the same two effects the screens were writing by hand.
One deliberate behaviour change: the framework normaliser drops `config` when it replaces an app.
The fourteen copies did `{ ...node, appType: fallback }`, keeping the old app's config on the panel
the new app now owns. That is the opposite of what `setApp` does, and a config belongs to whoever
wrote it.
Headscale keeps a local useMemo. Its check is not "is this appType allowed" but "is the server
picker present at all" — a layout saved before that panel existed is discarded for the default
wholesale. That is about a panel being missing, which the allow-list cannot see.
QrTransfer gains a persist-back it never had: it normalised on read and threw the result away every
time.
Tests: normalizeLayout is pinned on reference-identity for a no-op, null always allowed, config
dropped on replacement, rebuilding only changed branches, and idempotence — because a normaliser
that does not normalise to itself makes the persist-back an infinite write loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`promptPrefix` was a workspace-context field: the email and browser screens set it, `WorkspaceView`
put it on the context, and `ChatPanelWrapper` read it back off. Only the chat app has ever understood
what the string is, so the framework was carrying an app's vocabulary between two places that both
know each other.
`components` already exists for this — a screen supplies its own component for a panel id, and
`PanelSlot` prefers it over the registry while still taking header and provider from the registry
entry, so a screen-mounted chat panel keeps its normal chrome. Both screens now do that, and pass the
prefix as a prop. `ChatPanelWrapper` is exported from the barrel for it.
Also removes the same prop from `WorkspaceLayout`, where it had no callers at all: every preview and
settings pane rendering through it was already handing its chat panels an undefined prefix.
The repo had no error boundary anywhere, so a single malformed stored layout took the whole app down
and the only recovery was a psql session. Two boundaries, because "recover" means different things:
- around the routed screen in DashboardLayout, with the dock and header deliberately left outside so
navigating away is itself a way out, plus a two-click reset of every `screens/*` layout for when it
fails again in the same place. Dashboards are not touched — they are user-created and hold content.
- around each panel app in PanelSlot, so one bad app leaves the rest of the workspace running. Its
recovery is "clear this panel", offered only when the layout is the user's to edit.
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>
The clone check keyed off `navigation.type`, which only reports `reload` for F5/Ctrl-R. Every other way
back into the app — Enter in the address bar, a link, re-opening the URL after the server was down — is
`navigate`, and threw away the name you typed.
Ask instead of guess: each tab holds an id beside its name, and a copy is a tab whose id is still held
by a live tab, which the original says over a BroadcastChannel. A refresh has nobody to answer.
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>
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>
sessionStorage is the per-tab store — separate per tab, survives reload and
navigation, dies with the tab. The route title is derived rather than assigned,
so navigating no longer wipes a name you typed.
Duplicating a tab clones sessionStorage, so a `navigate` that arrives already
holding a name is treated as a clone and drops it; fails soft.
Co-Authored-By: Claude Opus 5 <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>
nothing reads it and nothing writes it any more, but a refresh re-requests the
address bar verbatim, so one left over from before the path-based groups sits
there indefinitely looking like it means something. on a bare /chat it still
says which group you wanted, so upgrade it to /chat/g/<path> rather than
dropping it — an old bookmark keeps working. anywhere else the session decides
its own directory, so it is just removed. other params untouched.
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>
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>
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>
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>
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>
adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.
the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.
API — all owner-only, mounted on the existing users router:
GET /api/users list, plus the role enum so the UI never
hand-writes the names
PATCH /api/users/:id/role change a role
DELETE /api/users/:id remove an account
ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.
The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.
The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.
The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.
Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.
Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.
Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
two workspace screens backed by one app folder: the collection list on the
left, the selected calendar's agenda or address book on the right. both read
the officer-caldav sidecar through the /api/caldav auth proxy, which holds no
DAV credentials of its own.
the selected collection is a DAV path with slashes in it, so it lives in
?collection= on the same route rather than as a path segment — still in the
URL, still bookmarkable, no selection channel. an absent or unknown value
resolves to the first collection inside the panels instead of redirecting,
because until the list has loaded there is no canonical URL to redirect to.
the agenda is a grouped list, not a month grid: the sidecar returns RRULE
unexpanded, so a grid would have to invent occurrences the server never
claimed existed. a repeating event gets a badge instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings → Integrations → Calendar & Contacts sync. without this there is no way
to mint a credential from the app, and minting one was the first step of testing
the whole caldav feature on a phone.
the generated password is returned by POST and never again — it is stored as an
argon2 hash, so there is nothing to read back. that one fact drives the screen:
the new credential appears in a panel that stays until dismissed, with the
server url and username beside it, because once it is gone the only remedy is to
revoke and mint another.
the server url is read from window.location.origin rather than configured. it is
by definition the address that reached this page, so it is the one that will
work on the phone.
rows show the hint prefix and last-used date. "never used" is the tell that a
device was set up wrong, so it gets its own wording rather than a blank.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tailwind's preflight resets `list-style: none` on every ul/ol, and none of the
three prose scopes put it back. the indent was there, so a bulleted list just
looked tight — but an ORDERED list rendered with no numbers at all, which reads
as the model having emitted broken markdown. pasting the same text into an
editor showed it numbered correctly, which is the tell.
the `li::marker` rules were colouring a marker that was never drawn.
fixed in .chat-md, .file-viewer-md and .skill-md, plus the inline
.markdown-preview block in MarkdownEditor, which had the same hole. nested
levels follow the usual convention (disc/circle/square, decimal/alpha/roman) and
task-list items drop the bullet, since the checkbox is already the marker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reloading /chat/<id>?cwd=<dir> landed on an empty general_chat_sessions instead
of the conversation. one line did both halves of it:
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`)
that ran on session:init, and session:init's sessionId is officer's own
per-connection key — websocket.ts mints it as `msg.sessionId || randomUUID()`.
/chat/sessions/:id resolves a CLAUDE TRANSCRIPT uuid, so the address bar ended
up naming something no lookup could find; the detail fetch 404'd and the catch
dropped you into a blank chat. the template also had no location.search, so
?cwd= — added later, for agent grounds — was thrown away every time the socket
connected, which is why the pwd picker fell back to the default group.
the transcript uuid is only known once the turn reports it, and it only started
crossing the wire in 7b6ca5f, so move the rewrite to `result`, use
claudeSessionId, and carry the query string through untouched. new chats gain a
working permalink too — /chat/new used to become an unresolvable id the same way.
mobile back had the same query-string hole; it now keeps the search.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Right-click an entry and agents whose triggers match appear under their own Run Agent
submenu — deliberately not folded in with tasks, because an agent run is a chat session and
not a job, and one menu promising both would lie about what a click does. The modal shows the
absolute target path, autofills entry_path with it (tilde expansion stays the server's job),
and on Run links to /chat?cwd=<runs dir> instead of a queue entry: there is no job row to view.
Trigger matching and category grouping are now shared with tasks rather than duplicated, and
the task input form is reused as-is.
Rescan counts agents and invalidates their caches, so a new AGENT.md shows up on the button
rather than after the 60s staleTime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which project group you are looking at is addressable state, so /chat?cwd=<dir> has to be a
link anyone can hand out — it survives a refresh and an agent run can point straight at its
own runs directory. Was usePanelChannel('chat:active-cwd'), which per the navigation audit is
for signals and refresh buses only. Row links and New Chat now carry the query string along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-photos owns the whole Immich contract: the instance URL and the API
key live there and nowhere else, and the platform side is an auth-gated
forwarder holding no credentials. The route surface is an allow-list keyed on
the first path segment, so admin, auth, api-keys, sessions, jobs, system-config
and libraries are unreachable by construction rather than by enumeration.
The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums,
people, favorites, sharing, archive, trash — because the point of a sidecar
screen is to reproduce what the upstream already ships, then extend it. The
timeline reads Immich's columnar time-bucket format directly; selection lives
in the URL per docs/navigation-audit.md.
Two things worth knowing for anyone touching this later:
- `duration` is an integer count of milliseconds in Immich 3.0. It was an
HH:MM:SS.mmm string before, and every stale example still shows that form.
- the map container is sized with h-full/w-full, never `absolute inset-0`.
maplibre's stylesheet sets `position: relative; overflow: hidden` on the
element it is given, and an unlayered vendor rule beats Tailwind 4's layered
`.absolute` regardless of source order — so the div collapses to height 0 and
clips its own canvas away. Nothing errors: the GL context is healthy, tiles
download and pixels are drawn into a buffer nobody ever composites.
maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file
from import.meta.url, which Officer's index.html fallback answers with HTML.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TEMPORARY / EXPERIMENTAL, at the owner's request, after deedy/qr-data-transfer (QRFerry).
Two panels: one loops a file as QR frames, the other scans them through the camera and
rebuilds it. Entirely client-side — nothing about a transfer reaches the server, which is
the point of the technique.
NOT RaptorQ, and that is the one real design decision here. QRFerry carries RFC 6330
fountain-coded symbols so a receiver can rebuild from ANY sufficient set of frames. This
uses a plain indexed carousel instead, for two reasons — the second being the deciding one:
1. RFC 6330 is days of work and unpleasant to debug.
2. The sender and receiver are being reimplemented on iOS and Android. A format one person
can re-derive from protocol.ts in an afternoon is worth more here than optical
efficiency. Every frame is independent, self-describing, and parses with a string split.
The cost is honest and written down: without fountain coding you must eventually capture each
specific frame, so a miss waits for the next pass rather than being covered by surplus. Fine
for a few hundred KB on a steady camera; it degrades where RaptorQ would start to pay for
itself.
Details that matter for the phone implementations:
- base64url, no padding — ':' and '/' would collide with the field separator.
- CRC-32 of the whole file in the meta frame, checked after reassembly. The test pins the
reference value for "123456789" (cbf43926) so any stock implementation will agree.
- The meta frame repeats every 12 frames, so a receiver joining late learns the filename and
total without waiting a full cycle.
- Error correction level L: frames are short-lived and repeated forever, so QR capacity is
better spent staying sparse enough to scan than on recovery.
16 tests over the protocol, which is the spec the other implementations should match.
The receiver needs a secure origin for camera access; over the tailnet with HTTPS that holds,
and it says so plainly rather than failing silently on plain http.
Adds qrcode and jsqr. @types/qrcode was already present and orphaned — its runtime package had
been removed with the chat-channel cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first producer, so the pipe can be exercised from the UI before any real event is wired
to it. Marked TEMPORARY in the source and meant to be deleted when a real producer replaces
it.
POST /_officer/notify now takes the user from the X-Officer-User header the proxy injects
when the body omits it. A producer inside the tailnet says who to notify; a browser reaching
this through /api/notify cannot know its own id, and the platform has already authenticated
whoever sent it. Body still wins where present.
The button sends { type: 'test' }, which fans out to every configured channel — Discord
today, APNs and FCM the moment their credentials exist — and toasts what each one reported,
so "no channels configured" is distinguishable from "sent".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three existed to drive the platform from a chat app. The phone app does that now, so
they are dead weight — three bot gateways, three command parsers, account pairing, admin
config screens and bot tokens sitting in the database.
Gone entirely: telegram/ and whatsapp/, discord/'s bot and command handler, the shared
channel plumbing they were the only users of (pairing.ts, send-and-await.ts, types.ts,
routes.ts and its 20 config/pairing/status endpoints), the six settings components, and
their sections in the integrations screen.
What Discord keeps is the one piece worth keeping — pushing a message out — as
notify/discord.ts, configured by DISCORD_WEBHOOK_URL in the env. No UI, no pairing, no
stored credential, and it never throws: a notification that fails to send is logged and
dropped. Unset means notifications are silently skipped, which is the default state.
Kept deliberately: send-claude-code.ts and send-opencode.ts. They live under channels/ but
have nothing to do with chat apps — they are how /chat and the pipeline executor drive an
agent turn.
Also drops four dependencies with no remaining importer (discord.js,
node-telegram-bot-api, whatsapp-web.js, qrcode), and the /sync-now route added to the email
sidecar an hour ago, whose only consumer was the channel handlers.
The "Channel Models" settings section stays, with its description corrected — it is keyed
off the general access policy rather than anything channel-specific, so it governs non-owner
accounts, not chat apps. Whether that whole class still earns its place is the open
question already noted against origin-validation.
Not touched: the telegram, whatsapp and discord rows in server_integrations, which still
hold their bot tokens. Deleting rows is a different kind of decision and the SQL is in the
handover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deletes the last of the projects/apps cluster: the published-app store
(/api/apps + /api/app-serve), the project dev-server and its websocket
proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite
they were the only consumers of, and their frontend — the Preview panel,
the UserApp panel/header, useUserApps and the /settings/apps screen.
also drops getUserProjectsDir and getUserAppsDir, the ProjectType and
ProjectDefinition types, and the 'dev-server' websocket provider from
server.tsx. nothing on disk is touched.
1440 deletions, 31 insertions. tsgo clean.
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a /invoices workspace screen replicating the parts of the invoiceshelf web ui
that are actually used: dashboard, invoices, estimates, recurring invoices,
payments, expenses, customers, items and reports.
one document editor serves invoices, estimates and recurring invoices — they
share the line-item table, the discounts, the taxes and the totals, and differ
only in a header strip. the arithmetic is a port of upstream's
use-document-calculations, rounding points included, because the server
re-validates the totals it is sent. money is integer minor units throughout and
is converted to major units at the edges only.
selection and editor state live in the url (`/invoices/:section`, `?selected=`,
`?edit=`), so the nav highlight is derived rather than held, back closes an
editor instead of leaving the screen, and a half-written form survives a reload.
sending a document is behind an explicit confirmation — it emails the customer.
built against the running 2.4.2 instance rather than the 3.0 checkout in
_references; the two differ materially. typechecked, not yet exercised in a
browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sections were held in a usePanelChannel, which is exactly the opaque
click the navigation audit catalogues: the id lived in an onClick
closure, so a section could not be linked to, opened in a new tab or
reached with the back button.
/headscale/:section is now the source of truth. nav items are real
NavLinks with the active class coming from the router rather than
derived in js, and the screen redirects bare or unknown sections to a
canonical url so the highlight always matches the address bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>