A task row is officer's own invention, synthesised from the harness's system.task_started, and
nothing corresponding to it is ever written to Claude's transcript. So rebuildTranscript can only
produce user/tool/assistant rows, and sync:live deliberately carries no messages — which left the
background-task tray empty after a mid-task refresh even though the work was still running.
Fold the durable log on attach into started-minus-notified and hand that back on sync:live. The
same read now supplies the cursor, so this costs one query rather than two. Finished tasks are
excluded: replaying those would resurrect rows already seen to resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refreshing mid-turn appeared to kill the agent's output. It never did: the
session survives a dropped socket, the agent keeps generating into it and keeps
committing durable events, and `close` only detaches the socket and arms an
hour-long idle timer. What broke was purely delivery — and the reconnect path
that would have fixed it could not fire, because the browser came back having
forgotten officer's session key. It lived in page state. The only id left was
Claude's transcript uuid in the URL, and nothing accepted that.
So accept it. `attach` carries the uuid, and the agent's on-disk session map —
the single record relating the two — turns it back into the key everything else
is written in terms of. The uuid now also goes out at `system.init` rather than
only at `result`, which is what makes the first turn recoverable at all: until
now a chat had no address until it had finished, and a long first turn is
exactly the one worth reconnecting to.
`sync:live` deliberately carries no messages. The harness writes its transcript
as it goes, so the HTTP load on landing already supplies the past; sending the
server's record of the same messages on top of it would duplicate them, and
there is no shared id to reconcile the two by. Attach hands over the rest of the
turn, the half-written paragraph the transcript cannot hold, and the session's
cursor head — that last one so a *later* drop replays from the head instead of
re-delivering the whole conversation from zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tool rows used to close on a five-second timer, so the list shifted under you
while you were reading it and a call you had opened re-collapsed on its own.
open-ness is now derived — a row is open because it belongs to the live turn,
not because it rendered recently — and your own toggle lives above the
virtualiser, which was throwing it away every time a row scrolled out.
the previous turn now folds when you send the next message, and it folds
visibly: the new fold is born holding the rows it replaces, so the height is
unchanged across the swap, then closes over 260ms. reopening an old
conversation still renders collapsed; expanding work is only a service to
someone watching it happen.
autoscroll was failing for three compounding reasons. it was smooth, and a
smooth scroll is by definition away from the bottom for its whole duration,
so new content interrupting it left the view stranded. leaving the bottom was
read as intent regardless of cause, so the interrupted scroll — and any row
measuring past its 150px estimate — silently disarmed pinning until you
scrolled down by hand. and nothing watched for the ResizeObserver correction
that arrives after the estimate, which keeping tool rows open made much worse.
pinning is instant now, intent comes from real gestures, and the list re-pins
when the measured total changes.
25 tests over which controls exist in which mode and whether each calls the handler it is named after. interactive, locked, isMobile, isLastPanel, maximized and an app's own zoomable/transparent flags combine in six separate ternaries; a control present in a mode that should not have it is a way to edit a locked screen, and a control missing from one that should is what the close button was.
Drives PanelSlot directly rather than through WorkspaceView, so the workspace can be put into states a whole view cannot easily be pushed into — maximized, mid-swap, mobile. Nothing new found: the close button is the only defect the chrome had, and it was pinned last commit.
TrafficLights took onRemove and isLastPanel but called onClearApp either way — isLastPanel only chose the tooltip. Closing a panel from its own chrome was impossible: the panel stayed, emptied, and the context menu was the only working path. Worse than cosmetic now that identity lives in the layout — clearing the app drops the config that named the panel's agent while the panel survives to be renamed by whatever is put in it next.
Also wraps the ephemeral pane's sizing effect in a try/catch: react-resizable-panels asserts rather than no-ops when asked to size a group it has not laid out yet, and an assert thrown from an effect aborts the commit — taking the whole dashboard down for a file preview's geometry.
section 9 now carries a concrete instance of its own argument: a test found a bug that
two careful readings of the file had not, in code written three days earlier to prevent
exactly that failure. also records the two bun/testing-library harness facts that cost
more than the fix did, since both present as an unrelated file breaking for no reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the store every dashboard layout is written through had no tests. writing them found
a live defect on its rollback: the guard asked "does the cache still hold what i wrote?"
by reference, and setQueryData runs react query's structural sharing, which rebuilds an
object rather than storing the one it was handed. verified against 5.101.4 — an object
comes back !==, a string comes back ===. so the check was false for every container the
store exists to hold: every layout, every config.agentName. a refused write kept its
optimistic value while the toast said it had been rolled back, and the change vanished at
the next reload. only primitives ever reverted, which is why it went unnoticed.
replaced with a per-key write sequence, which asks the question the identity check meant
to ask — has anything written this key since — and does not depend on identity at all.
14 tests: readValue's kind guard, the optimistic write and its updater composition, and
five on revert including the object regression pin.
also moves testing-library's cleanup into test-setup. it auto-registers afterEach at
module import time, so bun attaches it to whichever file imports the library first and
every later file silently gets none. adding this test file was enough to break fourteen
assertions in DataTable.test.tsx, which does not import it. preload has no file scope, so
registering there removes the ordering from the question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixteen custom properties and eight tailwind color utilities carried over from
the shadcn starter. There is no sidebar component in the repo and no
bg-sidebar/text-sidebar-foreground/... class anywhere, in either theme block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the pattern that came out of the refactor (path segment vs query param
vs stays-a-button), the grep that re-checks it, and the fact that BackButton —
which this section named as a standard building block — was dead and is gone.
Also folds in the two selections the audit never listed: the email folder and
the Soulseek room/peer rails. Neither was in the findings table; both were found
by sweeping for useGlobal<//useState after the listed rows were closed, which is
worth recording as the reason the table alone was not enough.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last two selections in Soulseek still held in useState. `?room=` and
`?peer=` now own them, the rails are links, and the leave/close buttons stay
siblings of the anchor.
Both rails auto-selected the first entry on load, which is the reason the
selection was local: there was nowhere to put an answer the user had not given.
The bare section is a real state now — nothing open — and both panels already
had the empty pane to say so. Rooms' pane said "Join a room to start chatting"
unconditionally, which was wrong once you could be joined to rooms with none
open, so it now distinguishes the two.
Join and "message a user" stay buttons: each writes something and *then* opens
it, which a link cannot express.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last selection still living in a global. `?folder=sent` is now the state,
the folder pills are links, and the open email carries it — a bare
`/email/:id` would have dropped the query string and snapped the list back to
inbox, so the row links and the arrow-key navigate pass it through.
The auto-switch to "all" when the inbox is empty writes with `replace`: it is
the app correcting its own default, not a place you chose to be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ui/toaster.tsx` was mounted in frontend.tsx and rendered a permanently empty
list: nothing anywhere imports `useToast`/`toast` from `ui/use-toast.ts`. The
app's real toaster is sonner, which is mounted beside it and has four callers.
`@radix-ui/react-toast` stays declared in the two package.json files on
purpose — installs are frozen, and dropping it means a deliberate
`bun install --no-frozen-lockfile` and a read of the lockfile diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jobs step deep-link: skipped, and measured first — selectedKey is plain
useState, not a channel, so it breaks none of this document's rules. The only
thing anchor semantics would buy is a deep link nobody asked for.
Preview slug: void, there is no Preview app.
FileBrowser widget: stays local, and M4 turned that shrug into a rule — only a
workspace guaranteed to host one browser may own the address bar.
Phases 1-4 are now closed except H4 and the New Chat button, both of which
live in the chat nucleus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four navigates were flagged; one was real. The two ProjectPreview lines are
void — Projects was deleted in July. Of the two in DashboardPreview, the
create path writes the dashboard and then goes there, which a link cannot
express, so it stays. The Open Dashboard button in the edit form was pure
navigation and is now a link.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit said adopt the shared BackButton. It is not shared: zero importers
since the initial commit, no barrel entry, and a label-plus-underline shape
that fits none of the icon-only back controls here. Adopting it would have
redesigned the Jobs header under cover of a navigation fix.
So: a Link, matching what ScriptJobDetail and DownloadJobDetail already do,
and the dead component goes. useNavigate had no other caller in
PipelineJobDetail and goes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dock, Header and the mobile sheet were computing active state from
useLocation with two copies of the same startsWith helper. react-router's
NavLink already knows. end is set for Home only: without it NavLink treats
'/' as an ancestor of every route, and with it on the others a detail route
would lose its highlight.
Segment matching is stricter than the string prefix it replaces, which is
what was meant all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/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 m7 ranked this medium because "every caller inherits the opaque click". there
are no callers. nothing has imported Combobox since the initial commit, there is no
barrel that re-exports it, and nothing anywhere sets `href` on a SelectOption — so the
navigate, the separator that only appeared for href options, and the href field on both
declarations of the type were all unreachable.
writing anchor semantics into a component that is never rendered is building, not
fixing. the Command primitives it used stay; AIHarnessesSection needs them.
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.
the file browser's currentPath was useState, so back and forward did nothing and a
folder could not be linked to. it is `?path=` now on /files, and the breadcrumbs are
real links.
opt-in, keyed on the parsed workspace identity rather than the base path: a dashboard
can hold two file browsers and one shared param would move both, while an unscoped
panel (cwd `~`) sits on dashboards too, so `basePath === '/'` would have caught the
wrong ones.
two things the audit line did not know. `?view=` is ephemeral — useFileViewerPanels
wipes it on mount — so `path` is this screen's first durable param. and four
setSearchParams({...}) calls replaced the whole query string, which would have made
opening any file silently reset the folder to home; they go through a setViewerParams
helper now that carries `path` across.
folder rows stay buttons. cmd/ctrl/shift-click is already multi-select in FileItem and
open is double-click, so anchor semantics collide with a gesture that exists. that is a
product decision, not a defect — written up for the owner rather than guessed at.
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 `/`.
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>
The todo entry said the file-viewer registration was dead; tracing it confirmed that and turned up the
reason it looked alive — the ephemeral file viewer is a different mounting path entirely. Recorded, with
what was checked in the database before deleting anything.
CLAUDE.md, navigation-audit.md and workspace-panels.md all listed FILE_VIEWER_CHANNEL among the
legitimate refresh/signal channels. It never had a publisher, and no longer exists.
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>
Two entries in 5.7 were wrong. HostTerminalWrapper was fixed in c92b51c, when
all three wrappers moved onto useTerminalSession — the item had simply not been
re-read since. And useTaskRunner does not abandon a running task: `stop` is sent
from the modal's Stop button, and closing the socket kills the process tree
server-side. Both were written from the hook alone without following the call
into the modal or the executor.
VideoPlayer and the remaining VideoPlayer-shaped case are left alone on purpose,
with the reason written down rather than the item deleted.
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.