/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 `/`.
Each item re-verified before deleting; three of the ten entries were stale
and are corrected in place rather than silently fixed.
- WorkspaceLayout's isMobile/mobilePanelId/onMobileBack: none of its ten
callers set them, so the mobile collapse they fed was permanently off in
that renderer. WorkspaceView passes the same props to WorkspaceRenderer
itself, where they are live.
- fixedHeight on AppRegistryEntry, and getFixedHeight with it: no app has
ever declared one, so it only contributed undefined. The flex-column
branch it shared with fitContent stays, keyed on fitContent alone.
- getDefaults: getAllDashboardState already folds the defaults row into the
one payload the client fetches, which is why it never got a caller.
- upsertScreen's terminals/hostTerminals: never read is right, never
written was not — it inserted them, which is why all 15 rows hold {}.
The columns are left in place; dropping them needs a db:push, and this
tree holds another agent's uncommitted schema file.
- SELECTED_DASHBOARD_KEY: H2 (01365cb) replaced it with ?selected= four
months ago and it has had no reader since.
- ui/sidebar.tsx and the stray ui/hooks/ beside it. use-mobile was not
orphaned as claimed — the sidebar imported it — and the use-toast in
there was a near-identical copy of the live one.
- findChildById's unreachable duplicate condition, and the doc comment that
described the wrong behaviour rather than the code being wrong.
Left deliberately: DragOverlay/LayoutEditor (gated on 5.3, an owner
decision) and the two chat-owned channels, whose docs are fixed here even
though the publishers are not mine to delete.
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 microphone was the loud one: DictateDialog's teardown was guarded by
`if (!showDictate)`, which can never be true, because a cleanup sees the props
of the render that registered it and only the open render registers one. So it
never ran, and the mic, the AudioContext and the rAF loop stayed alive for the
life of the tab. useAudioRecording had no unmount cleanup at all — closing a
Chat panel mid-recording did the same thing, with no way to switch the
recording indicator back off. Both now release on unmount; the second is pinned
by a test that records, unmounts, and asserts the track stopped.
The rest is the same shape. Two sockets registered a listener once and held the
first render's callback forever — usePipelineRunner's carried a captured
streamingText, so a re-render mid-run would have folded every later event into
a stale buffer. PanelSlot built its default header as a component *type* inside
render, which React cannot match against the previous one. WorkspaceView handed
every panel a fresh context object on every render, including each frame of a
maximize animation.
VideoPlayer's comment claimed a dependency list that the code did not have; the
list is fine (sendReport never changes identity) and the comment now says why
that has to stay true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The remount table in todo §5.2 was reasoned from the code and never observed, so
this mounts the real WorkspaceRenderer against a mount-counting probe and lets
the real layout-utils mutators produce the "after" tree. Eight cases.
It found one the reading had missed, and it is the cheapest of the lot. ChildEntry
returned `<>{children}</>` for the first child and `<><Handle/>{children}</>` for
every other, so the panel sat in fragment slot 0 when it was first and slot 1
when it was not. Remove the leftmost of three panels and the second one finds a
ResizableHandle in the slot it used to occupy — different element type, so React
unmounts a panel that nothing happened to. Scroll position, media playback, a
transcode, and for a chat panel a re-read of the durable log, all thrown away
because a neighbour was closed. Now the handle slot is always there, holding null
when it is not needed.
The other cases confirm what the doc said but for a different reason. Splitting
against the parent direction, and a two-child group collapsing, both change the
element *type* at that position — PanelSlot becomes ResizablePanelGroup, or the
reverse. React reconciles by type before it looks at keys, so the "reuse the id
so the key doesn't flip" fix the doc proposes would not have moved either one.
splitInner and insertPanel both ended with `100 / newChildren.length` applied to
every sibling, so splitting any panel in a group discarded every proportion in
it. A deliberately narrow sidebar became an equal column the first time anyone
split the panel next to it — and there was no way to get it back except by
dragging the splitter again.
The new sibling now takes half of the target's size and nothing else moves. One
helper for both call sites, because the drop path (movePanel -> insertPanel) had
the identical bug and would otherwise have kept it.
Three tests. Two of them were already there asserting the even split, written
against the old behaviour on purpose; they now assert the new one. The move test
is new and documents the interaction worth knowing: removePanel renormalises the
group when the panel leaves, so a move reads as renormalise-then-halve.
Two second implementations, both removed rather than fixed.
DashboardPreview minted template panel ids with its own module-level counter,
tpl-1, tpl-2, no entropy, reset every page load. Two dashboards built from
templates in the same page load held panels with identical ids — and a panel id
is not decorative any more: agent_panels addresses an agent by
(dashboardId, panelId), and terminal-conn-<panelId> and file-viewer:<panelId>
key persisted state by it. The templates now call the core uid(), which is
exported from the Workspace barrel for the first time so there is one minter.
metasToRegistry is Object.fromEntries, so two apps sharing a key means one app
stops existing and every panel holding its appType renders the other. The todo
asked for a throw in dev; a throw takes down every dashboard at runtime for a
mistake made at edit time, so this is a test over the real meta list plus a
console.error. All 44 keys are unique, and the test now says so rather than the
doc.
Getting the real list into a test needed test-setup.ts to provide localStorage:
MusicPlayer/useLyricsOpen.ts reads it at import time, so the whole app graph was
unimportable from a test. That unblocks testing anything that pulls in a panel
app.
Also deletes officerdev/src/useAppRegistry.ts — a stub returning {} with a
different shape from the real hook, imported by nothing.
The edit branch of the dashboard form rebuilt the layout from the template on every submit, then wrote
it. So renaming a dashboard, or fixing a typo in its description, silently threw away however its
panels had been arranged and whichever apps were in them. The template is a seed picked once at
creation; it is not a description of the dashboard as it now stands. It is now only re-applied when
the user actually picks a different one.
A rename also dropped `ws-terminals-<id>` and `ws-host-terminals-<id>` without carrying them over, so
every shell the dashboard held was abandoned: the panels came back empty and the processes stayed
alive with nothing pointing at them. Both maps now move to the new key with the layout.
The order those keys go into the PATCH body is load-bearing and now says so — the server walks the
object in insertion order, `ws-layout-<new>` upserts the row while `ws-terminals-<new>` only updates
one, and `ws-layout-<old>: null` deletes. Written the other way round the terminals 404.
Verified against the running server rather than by reading: seeded a dashboard with a layout and both
terminal maps, sent the rename PATCH exactly as the client now builds it, and read the rows back —
layout, terminals and host terminals all arrived under the new id and the old row was gone. The
no-op case (same id, same template) now writes nothing at all instead of PATCHing the layout back to
itself.
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>
The sidecar has always sent `clients` on each session and this list has always dropped it, so a shell
you are typing into and a shell nothing is attached to rendered identically. It is now on the type,
shown as "N attached", and a zero earns the row an orphan badge — the count only helps if you do not
have to read it to notice.
Also records §5.1 and the whole of §1 in the todo, including that the diff-the-layout implementation
§5.1 used to propose is struck and why.
Three wrappers held three copies of the same `panelId -> sessionId` bookkeeping, and one of the three
still had the unmount cleanup the other two had removed: `HostTerminalWrapper` dropped its map entry on
every layout or route change, minted a new uuid on the way back, and left the host shell running with
nothing pointing at it. All three now share `useTerminalSession`, which forgets the session and kills
the shell from `usePanelClose` — a real close, and nothing that merely looks like one.
The kill request goes to `/terminal/_officer/sessions/:id`, which is also a fix. `RunningShells` was
asking for `/terminal/sessions`; the proxy strips `/api/terminal` and forwards the rest verbatim, and
the pty sidecar only answers under `/_officer`, so that route 404s. Verified against the live sidecar:
`/sessions` returns `{"error":"not found"}` and `/_officer/sessions` returns the list. The panel has
therefore always read "No shells running" and its kill button has always been a no-op — which is why
the orphaned shells it exists to surface were never actually visible.
A panel that owns something on the server — a pty, a lock — has had no way to be told it was closed.
`TerminalWrapper` says so in a comment: it cannot kill on unmount, because a drag, a swap, a mobile
panel switch and a genuine close are the same event from inside the component.
So the signal is raised where the intent is, not where the teardown is. `usePanelClose(panelId, fn)`
registers a handler; `WorkspaceView` fires it from `handleRemove` and from `handleSetApp` when the app
actually changes, and from nowhere else. Registration is deliberately never torn down — "unmounted" is
the ambiguous signal being replaced, so honouring it would reintroduce the bug — and handlers are
stamped with the workspace they were registered on so one dashboard's panel id cannot fire another's.
`findPanelApp` is what tells a real app change from re-picking the app already there, which `setApp`
treats as a no-op; without it every pick from the app menu would close a panel that never closed.
No app uses the hook yet. The terminals come next; a chat panel deliberately never will, since a chat
panel is a pointer to a server-side session and closing the window must not delete what it points at.
The panel-close signal (§5.1) was going to be a before/after diff of the layout tree — the todo
document says so. It cannot be. `movePanel` inserts through `newPanelFrom`, which mints a fresh
`uid()`, so a dragged panel's id is gone from the new tree while its app is still on screen; and
`swapPanels` exchanges `{appType, config}` between two ids that both stay put, so a swap reads as
two closes and two opens. Everything downstream of a close signal is destructive — a pty killed, a
session released — so a mechanism that fires on a rearrangement is worse than none.
Two tests, no production change. The signal has to be raised where the intent is known, at
`WorkspaceView`'s `handleRemove`/`handleSetApp` call sites.
`WorkspaceLayout` and the `createContext` default each spelled out the same eleven fields — every
interaction a panel can start, switched off. Two hand-written copies of one list is a list you fall
behind: adding a field to the context type only errors at the call site if it is required, and both
copies have to be found.
Named it. `inertInteraction` is what "this tree cannot be rearranged" means, and both places spread
it. `cwd` and `root` stay out of it deliberately — they say where the workspace is rather than what
can be done to it, and the inert renderer has no answer for `root`: its consumers only read it when
`cwd` is scoped, which no caller makes it.
`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.
`initialPath` and `defaultSort` reached `FileBrowserApp` from nowhere else — the panel wrapper was
their only caller, and it was passing the two context fields that had no setter. Both remaining
callers pass neither, so the whole chain below them was already running on its defaults.
That includes `isolated`, which was `!!initialPath` and therefore always false: the unscoped browser
has been mirroring its folder into `files/currentPath` unconditionally, which is what the comment
beside it describes. Same behaviour, one fewer flag that reads as if it sometimes fires.
`initialFilePath` and `defaultFileSort` were declared on the workspace context, plumbed through
`WorkspaceView`'s props and read by exactly one panel wrapper — and set by zero callers. The sort
shape in particular (`{field: 'name'|'size'|'type'|'date', direction}`) is file-browser vocabulary
sitting in the framework's type file and re-exported from two barrels, so every app that imports the
context could see it.
Nothing changes at runtime: both were always undefined, which is what the wrapper now passes by
omitting them.
`dashboardId` in the workspace context was `workspace.key` — `ws-layout-<id>` or `screens/<name>` — and
three apps parsed its format to work out what they were mounted on. It is now `workspace`, a
`{kind, id, key}` parsed once by the framework.
The key survives on the result and is still what gets stored: `agent_panels.dashboard_id` holds it, so
the wire value is byte-identical and no named agent orphans. `kind` and `id` are for deciding.
Two behaviour changes fall out. An unrecognised key is no longer treated as a dashboard — the old
`!startsWith('screens/')` test called anything that was not a screen a dashboard, which would have let a
panel register an agent against a workspace with no row to hang it on. And `ChatPanelWrapper`'s
`dashboardId === 'email'` branch is gone: it compared against a bare id no producer ever emits, because
the only writer is `WorkspaceView` and the only other one, `WorkspaceLayout`'s `dashboardId` prop, was
passed by zero callers. That prop is deleted.
Also here because it is the same defect as b0a32ae one file over: `WorkspaceLayout`'s resize handler
computed a tree from a captured `layout` and `WorkspaceRenderer` debounces it 500 ms. Updater now.
The dashboard-state cache had staleTime: Infinity and there is no invalidateQueries anywhere in the
repo, so it was fetched once per page load and never again: two windows diverged permanently and neither
was ever told. It now refetches on focus — with three non-default guards, because this cache is
optimistic and a refetch that started before an in-flight PATCH landed would overwrite the value we
already showed. Never on mount (splitting a panel mounts a fresh consumer, which is exactly when a write
is in flight), never on reconnect, and on focus only after a short quiet period with nothing in flight.
The PATCH stopped assembling a full state blob it then returned to nobody — three SELECTs per splitter
release, thrown away, and a caller that did read it would be reading state assembled before whatever
concurrent write it raced.
And the last three `.catch(() => {})` in this family are gone: dashboard create, rename and delete build
their own multi-key patches and so bypass the hook. They now go through persistDashboardState, which
keeps the in-flight bookkeeping honest and, on failure, invalidates rather than reverts — there is no
single previous value to swap back once the roster has been rewritten, and a refetch is the only thing
that makes the list agree with the server. A failed delete used to leave the dashboard gone from the list
and alive on the server, reappearing at the next reload with no hint why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every mutation in WorkspaceView computed its new tree from the `layout` its callback closed over, and two
of the paths are not immediate: the resize debounce fires 500 ms after the drag began, and a window
resize fires onLayout on every group at once. So the later write was computed from a tree that predated
the earlier one and silently undid it — remove a panel just after dragging a splitter and it came back.
Worse now that panel identity lives in the layout: the resurrected tree carries an older `config`, so a
panel that was just given an agent's name reverts to anonymous and the agent stops being addressable
through it. All eight now pass an updater to setValue, which composes against the current cache.
The debounce timer also had no cleanup at all, so it outlived the component. It now flushes on unmount
rather than dropping — with an updater the early write is correct, and dropping would lose a splitter
drag made just before navigating away, which the no-cleanup version did at least persist.
Neither file is prettier-clean at HEAD, so neither was formatted; the new code is written to match.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout columns defaulted to '[]' — an empty array for a column whose only legal contents are a
LayoutNode object — and every upsert that omitted a layout wrote it. Creating a dashboard from the
dashboard list is exactly that path, so the key came back present, the client's `key in state` check
preferred it over the caller's default, and normalizeLayout called .children.map on it and threw.
Three layers, because none of them was enforcing anything:
- the columns are nullable with no default: NULL means "none stored", which is the truth
- getAllDashboardState omits the key when what is stored is not an object, so rows written before this
are repaired by the next write rather than crashing the read
- useDashboardState checks kind-compatibility before casting jsonb to T, and falls back to the caller's
default when it does not match. Only object-shaped defaults are guarded — a wrong primitive is a
cosmetic surprise, a wrong container is a crash.
Verified against the live DB: creating a dashboard with no layout no longer emits a ws-layout key, and
a row hand-set back to '[]' is omitted too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Four defects, one shape: a write that returns 200 and lands nowhere.
- Unknown keys were dropped by a chain of `if (match) continue` with no `else`. The three prefixes
CommandTerminalWrapper actually writes — tmux, nvim, claude-code — were among them, so those panel
maps lived in the React Query cache only: every reload minted a fresh uuid and abandoned a running
pty. They now live in a `panel_state` bag on the dashboard row, and an unmatched key 400s.
- `ws-terminals-{id}: null` fell through to an upsert, writing NULL into a NOT NULL column on a live
dashboard and re-INSERTing a deleted one. Renaming a dashboard sends exactly that, paired with
`ws-layout-{id}: null`, so the old slug came back as a zombie row in the dashboards list.
- HostTerminalWrapper and CommandTerminalWrapper built their state key straight from `dashboardId`,
which is a workspace *key* (`ws-layout-<id>`), while TerminalWrapper stripped the prefix. The server
read the un-stripped form back as a dashboard id and created it. One rule now, in state-key.ts.
Verified against the live server: unknown key 400s, the three prefixes round-trip, a null on a live
dashboard is a no-op, and the rename sequence leaves no zombie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The optimistic cache made a refused write invisible: the UI stayed correct until the next reload, at
which point the change was simply gone. That is tolerable for a pane size and not for a chat panel's
agent name, which is the address a peer agent is delivered to.
Roll back only if the cache still holds exactly what we wrote — writes to one key overlap freely (a
window resize fires one per group) and rolling back over a later successful write would turn one
failure into two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
every multipart upload through /api/<sidecar>/* arrived corrupted. bodyParser ran on
proxy routes and called parseBody for multipart, so hono cached a FormData on the
request; when the proxy then asked for the bytes hono re-serialised them from that
cache with a NEW boundary, while the proxy still forwarded the ORIGINAL content-type
header. header and body disagreed and the far side rejected it with
"Multipart: Unexpected end of form".
bodyParser now skips prefixes owned by createSidecarProxy, which register themselves
so a new sidecar cannot forget. the proxy also forwards the body as a stream instead
of buffering it, which drops the second in-memory copy of every upload.
note the bug report proposed skipping multipart in bodyParser outright; that would
have broken /upload, /file-browser upload and /bug-report, which do read a multipart
body from ctx.get('body').
Co-Authored-By: Claude Opus 5 (1M context) <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 composer already uploaded an image, split its data URL and put the bytes on the wire as
`images`. Nothing on the server read them. The `chat` ClientMessage had no such field, and the
prompt reached the sidecar as a bare string, so all the model ever saw was the client-generated
`[Attached image: …]` placeholder — a label describing a picture it was never shown.
The transport was never the obstacle: `query()` consumes an async iterable of user messages whose
`content` is an Anthropic `MessageParam`, and only `pushTurn` hardcoding a string kept it to text.
So `images` is threaded through the four hops that dropped it and turned into native image content
blocks at the end, renaming `mediaType` to the API's `media_type` at that last step.
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>
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>
signin refused any non-owner arriving through the web or mobile platform
origin: "This account can only sign in through its app." so a member could
hold a gitea grant and still never reach a page — verified as a live 403
before this change.
that rule was correct while single-user was the invariant. the only
non-owner accounts were music-app accounts, and there was no way to say
"this person may use the platform, but only these parts of it", so keeping
them out entirely was the honest answer. capabilities say exactly that now,
per feature, at both doors and on every request.
so superAdminOnly is retired rather than patched. the web origin and the
platform app get no path scoping — what their caller may reach is decided by
their role, not by their Origin. per-app path scoping stays for the
single-feature apps (music, vault, tail), where it still means something.
note this WIDENS who may sign in: any Active account can now authenticate
through the browser. that is the intended product change, and it is only
safe because the capability backstop runs on every request behind it —
which is why it lands after that, not before.
Co-Authored-By: Claude Opus 5 (1M context) <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>
the server half of the previous commit, which belonged with it. the frontend
guard needs both lists: absence from `routes` cannot tell a route this
account lacks from a route no capability claims, so without this the guard
permits everything.
Co-Authored-By: Claude Opus 5 (1M context) <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>