Commit Graph
100 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 63f7a14b6c stream request bodies through the photos sidecar
both forwarders buffered the whole request body into an ArrayBuffer before
re-sending it to Immich. with maxRequestBodySize at 4GB that put a phone's
video upload in the sidecar's heap for a hop that never reads the bytes.

callUpstream now sets duplex: 'half' so a stream is a legal body, matching
what createSidecarProxy already does on the platform side. the four JSON
callers are unaffected.

the platform proxy forwards no content-length, so the body already reached
us chunked; this extends that one hop to Immich. verified against the live
instance (3.1.0): bulk-upload-check round-trips a streamed body and returns
the right verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:45:01 +00:00
pastilhasandClaude Opus 5 ea59b5f1a7 photos: reach immich's private folder on an in-memory session
Locked assets are gated on elevation, and elevation lives on a session — an API key
has no `auth.session`, so no key permission or allow-list entry can reach them. The
gate sits inside the generic owner-access check, so a locked asset's thumbnail and
original are covered too, not just its listings.

So `/_locked` mints a session at unlock, holds it in memory for the elevation window,
and closes it on lock, on idle, or when the active immich account changes. Nothing new
is written to photos_config: the pin and the password are never at rest, and a full
compromise of officer's database still does not open the folder. The cost is that
unlocking asks for the immich password as well as the pin.

`auth/*` stays refused wholesale in routes.ts. The four auth routes this needs are
reached through named endpoints that each do one thing, and the elevated forward
carries three resources rather than the main allow-list.

Not yet exercised at runtime — the sidecar has not run this code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:02:22 +00:00
pastilhasandClaude Opus 5 8987898dd7 prettier: mobile-api-keys tables
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:44:54 +00:00
pastilhasandClaude Opus 5 555da9a170 api key contract for the mobile developer
docs/mobile-api-keys.md — what the server accepts, what changes in
monorepo-mobile, and the 401-vs-403 distinction, which is the one that
bites: clearing a good key on a 403 turns a member's missing capability
into a logout loop they cannot escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:44:41 +00:00
pastilhasandClaude Opus 5 ae275ee607 resolve api keys at the websocket doors too
there were four doors, not two. the ws upgrade in server.tsx and the
vault notifications socket each verified the jwt themselves, so a key
that worked against /api would have 401'd on cliamp — signed in and can
play audio would have been two different questions for the music app.

both now call resolveAuthToken. verified: owner key upgrades cliamp
(101), bogus key 401, member key 403 on terminal exactly as their jwt
is.

reset-password and verify-token deliberately keep verify() — they read
a purpose-scoped reset token and a key must not be spendable as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:40:04 +00:00
pastilhasandClaude Opus 5 ae54df7c30 api keys settings section
settings > integrations > personal > api keys. mirrors the dav app
password panel, which is the same problem: a secret that exists for one
response, so the new key stays on screen until dismissed rather than in
a toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:13:24 +00:00
pastilhasandClaude Opus 5 00997f5ff1 per-user api keys, resolved at both identity doors
a user can mint a long-lived key for an app or a device instead of
carrying a 30-day session, so multiple logins on the mobile apps are
per-device revocable rather than one shared token.

identity was being decided independently in userMiddleware and
originScopeMiddleware, each verifying the token itself. teaching only
one of them a new credential format is how those two stop agreeing, so
both now call resolveAuthToken and neither knows what a bearer string
is. verified: a member's key returns the same status as their jwt on
every route tried, 403s included.

a key carries its holder's full authority — not an escalation, it
equals what the password could already do. scoping wants a scopes
column, not a change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:10:14 +00:00
pastilhasandClaude Opus 5 b077671f19 make the notify identity rule testable, and fix what the test found
Extracting resolveNotifyUser into its own module immediately caught a hole
in the fix from the previous commit: a header that was present but
unparseable fell through to the body, so a browser could send junk in the
header, name any user in the body and win.

PRESENCE of X-Officer-User is the signal, not its validity — a malformed
header means a proxied request went wrong, and falling through hands the
decision back to the caller we just declined to trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:00:02 +00:00
pastilhasandClaude Opus 5 33d96f19ee end a turn that has gone silent, instead of generating forever
A turn could stop producing events and stay `isGenerating` indefinitely. Nothing
covered it: the idle timer answers the opposite question — how long a session with
NO turn in flight may sit before collection — and every client showed a spinner
with no timeout of its own, so a wedged turn presented as a chat that was still
thinking.

On 2026-08-08 one ran for seventeen minutes inside an auto-compaction, reached
over the socket to an iPad, and was indistinguishable there from a dead app. The
compaction is silent by design (the PreCompact hook is the only announcement, and
the code's own comment allows 2.5 minutes), so there was nothing to distinguish it
from.

A stall watchdog now rides every emitted event: any sign of life pushes the
deadline back, and expiry ends the turn the way a real failure would — isGenerating
off, idle re-armed, and an `error` the client can render. The agent process is
deliberately left alive, since it may still be working and the next turn resumes
it; what this guarantees is that the client is TOLD, which is the part that was
missing.

The budgets are generous rather than tight — ten minutes of silence normally,
twenty while compacting, re-armed from the PreCompact hook because that hook fires
as the long silence begins and the deadline the turn is holding was sized for
ordinary work. Killing a turn that was about to succeed is worse than the hang this
prevents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:01:10 +01:00
pastilhasandClaude Opus 5 6089eb18fa read Claude's OAuth from the macOS Keychain, and keep it ahead of expiry
On a Mac, Claude Code stores its credentials in the login Keychain and never
writes ~/.claude/.credentials.json — the only file this proxy knew how to read.
The workaround was to copy the Keychain blob into that file by hand, which is a
snapshot: a refresh ROTATES the refresh token and revokes the previous one, so
the two stores were not redundant copies but competitors, and whichever
refreshed second got `401 OAuth access token has been revoked`.

That is not hypothetical. On 2026-08-08 it took out every chat turn from the
iPad for six hours while the terminal CLI beside it worked fine — the harness
spawned, retried for three minutes and wrote the 401 into the transcript, which
from the app looks like an agent that simply never answers.

So on darwin the Keychain is the authority and the file is a mirror, holding the
same token rather than a different rotation of it. Everywhere else — every Linux
server — the file is still the authority and nothing changes. Detection is
process.platform, and a machine with no `security` binary or no such item falls
through to the file rather than failing.

Three recoveries, cheapest first:

- a watchdog checks every 30 minutes and refreshes when under an hour remains.
  It checks rather than refreshing on a blind schedule because each refresh
  rotates the token, so a needless one is another chance for the stores to
  disagree.
- an upstream 401 now RE-READS before refreshing. When a token has genuinely
  been revoked the machine usually already holds a good one, because Claude Code
  refreshed it into the Keychain minutes ago; spending our own refresh token
  there is what caused the divergence in the first place.
- only if nobody else has moved do we refresh ourselves.

The Keychain write goes through argv, which is the only non-interactive form
`security` offers, and matches on the service AND account pair — the account is
read off the existing item rather than assumed, or the update would silently
create a second entry instead of replacing the one Claude Code reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:30:08 +01:00
pastilhasandClaude Opus 5 2490392e57 run the gitea sidecar in the mac light profile
The sidecar fronts a REMOTE instance — its URL and token live in
service_connections, set from /gitea — so it needs nothing installed on the
laptop. That is what separates it from the sidecars left out of this profile,
which supervise a local daemon or container.

It also had to be classified either way: defineProfile throws at load on a name
that is in neither include nor exclude, so leaving it unlisted broke the profile
outright rather than merely omitting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:39:18 +01:00
pastilhasandClaude Opus 5 d56be0301d retire the single-user claim from the docs it outlived
CLAUDE.md asserted "single-user is a hard invariant, not a stage" while
users held six rows and role_capabilities held grants. Every doc that
repeated it is corrected here, in prose and in the code comments that
carried the same claim.

The accurate statement is narrower: one owner who bypasses every check,
other accounts holding only what their role is granted, and a set of
capabilities — terminal, chat, files, tasks, items, desktop, browser — that
are structurally ungrantable because they execute as the owner's OS user.

TODO.md gains a Multi-user section for what the read turned up: no way to
create a second account, dashboards.id colliding across users, authorize.ts
untested, pty/vault/opencode taking no identity, Radicale still owner_only.

claude-sidecar-isolation.md's open question is answered rather than left
open — the per-email spawn model is dead weight, because chat is an
execution capability and no second account can ever reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:58:44 +00:00
pastilhasandClaude Opus 5 ac64a7362b close three cross-account holes found reading the multi-user path
reset-password accepted any valid signed jwt as a reset token, including a
30-day session token — its sibling verify-token.ts already gated on
purpose === 'reset-password' and this handler did not. forgot-password mints
that claim, so the gate costs the legitimate flow nothing.

notify's DELETE /_officer/devices/:token deleted by token with no user
predicate: a token is the address of a device, not a secret, so any account
holding the notify capability could deregister another's device.
deletePushDevice now takes an optional userId — the route passes it, the
APNs/FCM dead-token paths deliberately do not.

POST /_officer/notify let a request body's userId override the
proxy-injected X-Officer-User. The header now wins where present, which is
what separates a signed-in browser from a loopback producer that has no
session to speak from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:58:36 +00:00
pastilhasandClaude Opus 5 7ce9289921 fix(hooks): useSessionState reset honours the current default, not a frozen one
Also found uncommitted in the shared tree; unrelated to agent panels, so it lands
on its own.

`reset` closed over `initialValue` from the first render, and its `useCallback` dep
list deliberately omitted it — with an eslint-disable to silence the warning that was
correctly pointing at the bug. Any caller whose default is computed (derived from
props, from a fetch, from another piece of state) got reset to whatever that default
happened to be on mount, which after the first render is the wrong value.

Reads through a ref instead, so reset always sees the current default. The
eslint-disable goes away because there is nothing left to suppress — the dep list is
honest now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:47:56 +00:00
pastilhasandClaude Opus 5 8773da5953 feat(chat): agent panels — named Claude panels that can hand work to each other
Committing work that was left uncommitted in the shared tree. I did not write it;
I reviewed it in full, verified it against the running system, and am landing it at
the owner's explicit request because no one currently owns it.

This REPAIRS master. `useAgentPanel.ts` shipped in dbe585f and calls
`/chat/agent-panels`, but `registerAgentPanelRoutes` existed only in the working
tree — so on master as pushed, every one of those calls 404s. The feature has been
half-landed since that commit.

What it is. A panel on a dashboard can be given a name ("frontend", "code-reviewer").
Naming it mints two things: a `sessionKey`, which is the panel's permanent continuity
(it keys the sidecar's on-disk resume map and `chat_session_events`, so the same panel
reopens the same Claude session), and a `handoffToken`, a bearer credential scoped to
exactly one verb. The agent in that panel is then addressable by name, and can pass
work to a peer on the same dashboard over `/api/agent-handoff`.

Three doors, deliberately separate:
  - `/chat/agent-panels` (browser, session-authed) — name / list / rename / forget.
    Mounted on the chat router rather than given its own prefix: these routes create
    and name Claude sessions, which is authority `chat` already grants. A second
    top-level mount would have meant a second capability entry claiming the same
    thing under a different name.
  - `/api/agent-handoff` (agent, token-authed) — peers and send. Unprotected by the
    session middleware and exempted in `capabilities/totality.ts` with its reasoning
    written down, because the caller is a subprocess with a token, not a browser with
    a cookie.
  - The transcript stays where transcripts live. DELETE forgets the address and the
    panel's claim on the session; it does not touch ~/.claude/projects.

Security, as verified rather than assumed:
  - The sender is derived from the token, never from the request body — there is no
    `from` field on the wire, so it cannot be forged.
  - Every lookup is scoped to the token's `userId` AND `dashboardId`, so an agent can
    only see and reach peers on its own dashboard.
  - `toAgentPanelView` strips `handoffToken` and `userId`, and it is the only shape
    the browser routes return. Confirmed by reading every return path.
  - Live-tested: a real token on `GET /api/agent-handoff/peers` returns 200 with
    correctly scoped peers; a bogus one returns 401.

Two judgement calls in the code worth knowing about, both already commented at their
site: the introduction turn inlines the handoff token into a runnable curl (a
single-owner MVP trade), and `agent_panels` carries no FK to `dashboards.id` because
that primary key is mid-rework to a composite.

Schema uses `uniqueIndex` throughout, never `unique().on(...)` — the rule that exists
because drizzle-kit mis-diffs named composite unique constraints and re-creates them,
which is what wiped seven tables on 2026-08-03.

NO `bun db:push` IS NEEDED. `agent_panels` is already live in Postgres with 6 rows;
the schema file is catching up to a database that already has it.

Verified: `bunx tsgo` clean, `bun test` 538 pass / 0 fail across 35 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:47:48 +00:00
pastilhas 302116d624 pin background tasks to a row of their own
a pin on each chip lifts it out of the strip and into a row above it, so the
one task you are actually waiting on stops sliding off the end as newer ones
arrive. more than one can be pinned; the pinned row scrolls like the other.

a pin outranks FINISHED_KEPT and the bulk clear both — it is an explicit
"keep this", and it would be useless if five newer tasks could still evict it.
pinning survives the task finishing, because the outcome is what you pinned it
for.
2026-08-07 20:43:03 +00:00
pastilhas 5624ed8e66 dismiss background task chips one at a time
the tray only had a bulk clear, so getting rid of one finished chip meant
clearing all of them. each finished chip now carries its own close control.

the pill becomes a div wrapping two buttons — a button nested inside a button
is invalid and the browser eats one of the two clicks. running chips stay
undismissable: the tray is the only handle on work still going.
2026-08-07 20:38:16 +00:00
pastilhasandClaude Opus 5 a70e4e7296 put recovered task rows back where the task started
A recovered row was appended, so it landed at the bottom of the conversation instead of beside the
call that spawned it. It has no timestamp, but it does not need one: the harness stamps the task id
into the output of the tool call that started it, and live the task:started event arrives right
after that tool result — so anchoring there reproduces the position the row would have had.

First mention wins, and that is the correctness argument: the id is minted by the call that spawns
the task, so nothing earlier can contain it. Matching the most recent instead was wrong, and real
data caught it — a diagnostic that grepped the transcript printed both live ids and pulled the rows
down beside itself. That case is now a test.

Moved out of the hook into its own module since it is pure and has nothing to do with React.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:47:20 +00:00
pastilhasandClaude Opus 5 66b9cf634c keep background task notifications out of the transcript
The harness delivers a finished background task to the agent by writing it as the user's next
message, so Claude's file holds a raw <task-notification> envelope as a user turn. Live it never
shows, because the same event travels separately as task:notification — it appeared only when a
refresh rebuilt the conversation from the file, as a bubble on the owner's side he never typed.

Same defect as INTERRUPTION_MARKERS and the same fix. Anchored to the start of the message so
quoting one inside a real message stays yours. Also skipped when picking a session's title, where
it is no more a title than a slash command is.

Verified against a live transcript: 38 user bubbles before, 32 after, the 6 removed being exactly
the notifications.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:40:29 +00:00
pastilhasandClaude Opus 5 7b7147001b fix email account creation: body parser, error shape, and non-Error throws
adding an email account failed with a bare "failed to add account" toast. three
defects stacked, each hiding the next.

the email sidecar's http.ts reconstructs what the platform's middleware used to
provide, but only did two of three — bodyParser was never remounted, so every
write route read ctx.get('body') as undefined and POST /accounts threw on
body.provider before ever reaching the credentials.

its onError then read `.status` off the thrown custom-error, which carries
`statusCode`. every deliberate 4xx fell through to the 500 branch and had its
message replaced with "internal error", so a rejected IMAP login and a genuine
crash looked identical. it also answered JSON where the rest of the api answers
errors as plain text. now mirrors hono.ts's handler rather than inventing a
second shape.

useClient threw a plain object, so the ~33 sites narrowing with
`err instanceof Error ? err.message : <fallback>` always took the fallback and
discarded the server's message. now throws an ApiError subclass keeping both
status and message, so those sites start surfacing real errors.

only email reads ctx.get('body'); every other sidecar is a pure proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:31:31 +00:00
pastilhasandClaude Opus 5 bffae5ef61 log how many background tasks an attach recovered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:25:48 +00:00
pastilhasandClaude Opus 5 d5a3bae367 recover still-running background tasks on reattach
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>
2026-08-07 19:17:05 +00:00
pastilhasandClaude Opus 5 6b4339052a reattach a refreshed browser to a running turn
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>
2026-08-07 17:59:26 +00:00
pastilhas dc5ad28aa2 keep a turn's work open until you supersede it, and stay pinned to it
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.
2026-08-07 17:29:16 +00:00
pastilhas 90c546c098 §3: re-measure the multi-user premise, and say why it is not mine to fix tonight 2026-08-07 14:43:48 +00:00
pastilhas 6eac14a2b7 close §9: PanelSlot tested, the section is done 2026-08-07 14:41:54 +00:00
pastilhas 2c00c6afa3 test PanelSlot — the chrome's mode matrix
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.
2026-08-07 14:41:23 +00:00
pastilhas 7addb8f1e3 note the WorkspaceView tests and the TrafficLights defect in §9 2026-08-07 14:38:19 +00:00
pastilhas bfa99671ca test WorkspaceView; fix the close button that never closed
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.
2026-08-07 14:37:47 +00:00
pastilhasandClaude Opus 5 873ccae32a record the defect the useDashboardState tests found
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>
2026-08-07 14:23:41 +00:00
pastilhasandClaude Opus 5 4f8046d7e9 test useDashboardState, and fix the revert it proved was inverted
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>
2026-08-07 14:22:26 +00:00
pastilhas 212903ecd4 correct a stale untested list: two of its four entries already had tests 2026-08-07 14:09:37 +00:00
pastilhas a81b7cfcde record the onSelect widening and what invoiceshelf being unconnected leaves unverified 2026-08-07 14:08:22 +00:00
pastilhas f46a603892 close the last three navigate-only menu items into links 2026-08-07 14:07:38 +00:00
pastilhas 15a960897f record the third sweep: a navigation that goes nowhere 2026-08-07 14:04:14 +00:00
pastilhas 19ba106265 make create dashboard here actually create a dashboard here 2026-08-07 13:58:28 +00:00
pastilhas f4fdc000b2 record the second sweep: opaque clicks a state grep cannot see 2026-08-07 13:21:26 +00:00
pastilhas ea1dae5280 make the invoices dashboard tiles and rows real links 2026-08-07 13:20:22 +00:00
pastilhasandClaude Opus 5 19beafa9dc delete the sidebar theme tokens nothing renders
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>
2026-08-07 13:07:26 +00:00
pastilhasandClaude Opus 5 62b5b1db6f close the navigation audit's cross-cutting section
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>
2026-08-07 13:05:46 +00:00
pastilhasandClaude Opus 5 5a2ef6a0a0 put the soulseek room and chat peer in the url
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>
2026-08-07 13:04:19 +00:00
pastilhasandClaude Opus 5 33492262b5 put the email folder in the query string
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>
2026-08-07 13:01:04 +00:00
pastilhasandClaude Opus 5 f3538cde25 delete the toaster that could never show a toast, and title the system settings page
`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>
2026-08-07 12:58:21 +00:00
pastilhasandClaude Opus 5 98636224eb close out the navigation audit's remaining decide-or-skip items
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>
2026-08-07 12:52:31 +00:00
pastilhasandClaude Opus 5 bbcb041ef3 make open dashboard an anchor, and close the preview-navigate verify
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>
2026-08-07 12:50:59 +00:00
pastilhasandClaude Opus 5 667d622918 make the jobs back button a link, delete the backbutton nobody used
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>
2026-08-07 12:48:34 +00:00
pastilhasandClaude Opus 5 39125b5028 let the router decide which nav item is active
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>
2026-08-07 12:45:59 +00:00
pastilhasandClaude Opus 5 1dc0eddde0 put the open plan in the url, and stop /api/plans reading outside its folder
/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>
2026-08-07 12:41:55 +00:00
pastilhas 32aa1e7cc3 delete the combobox instead of giving it anchor semantics
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.
2026-08-07 12:35:58 +00:00
pastilhas 990ead93b9 put the open file in the url on /code-editor
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.
2026-08-07 12:34:10 +00:00
pastilhas 5daa598b63 put the browsed folder in the url on /files
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.
2026-08-07 12:28:49 +00:00
pastilhasandClaude Opus 5 ec4aaaae8a put the open task log and the followed run in the url
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>
2026-08-07 12:19:47 +00:00
pastilhasandClaude Opus 5 a55ea1882a put the open capability in the url
/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>
2026-08-07 12:16:11 +00:00
pastilhasandClaude Opus 5 aef8619c6c put the music library location in the url
/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>
2026-08-07 12:08:04 +00:00
pastilhasandClaude Opus 5 374140d3a6 put the previewed browser tab in the url
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:56:45 +00:00
pastilhasandClaude Opus 5 6c47cbeb74 make the email url the selection instead of a mirror of it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:51:45 +00:00
pastilhas fd923bb9be give the soulseek workspace a url
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.
2026-08-07 11:45:33 +00:00
pastilhas 00332e275a put the monitor scope in the url
/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.
2026-08-07 11:41:16 +00:00
pastilhas 2502c33804 put the settings section in the url
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 `/`.
2026-08-07 11:38:21 +00:00
pastilhas 98ba61f135 record the sweep commit 2026-08-07 11:26:39 +00:00
pastilhas b419af32de sweep the dead code in section 8
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.
2026-08-07 11:26:33 +00:00
pastilhasandClaude Opus 5 f2ae10bd36 mark 5.10 resolved
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:15:00 +00:00
pastilhasandClaude Opus 5 c9735580fc declare panel channels once, and fix the bump that could lose a refresh
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>
2026-08-07 11:14:55 +00:00
pastilhasandClaude Opus 5 30fcab2bd3 mark 5.6 resolved, and stop three docs claiming a channel that has no publisher
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>
2026-08-07 11:07:36 +00:00
pastilhasandClaude Opus 5 9fcc9c278a seed the registries before render, and delete the dead file-viewer app
`<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>
2026-08-07 11:07:26 +00:00
pastilhasandClaude Opus 5 04ccb05b2c mark the effect-hygiene items resolved, and correct two of them
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>
2026-08-07 10:57:10 +00:00
pastilhasandClaude Opus 5 c0fae47cc7 release what a panel was holding when it goes away
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>
2026-08-07 10:56:58 +00:00
pastilhas e2017cbbf2 correct 5.2: the key was never the cause 2026-08-07 10:46:31 +00:00
pastilhas 56ca411cf3 a panel is no longer remounted for stopping being second
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.
2026-08-07 10:45:59 +00:00
pastilhas 34b40cb094 mark preserve-sibling-sizes resolved 2026-08-07 10:41:32 +00:00
pastilhas abea7a3a3d splitting one panel no longer resets the whole row
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.
2026-08-07 10:41:21 +00:00
pastilhas 4ce7839b31 mark the panel-id and registry-key items resolved 2026-08-07 10:38:59 +00:00
pastilhas 6fd60e59c4 one way to mint a panel id, and a registry that cannot silently lose an app
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.
2026-08-07 10:38:52 +00:00
pastilhasandClaude Opus 5 3ab57a5839 measure what a handoff actually survives
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:32:18 +00:00
pastilhasandClaude Opus 5 df00f6d7a5 tier b has no unblocked work left
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:16:09 +00:00
pastilhasandClaude Opus 5 49b635a489 drop §5.4's default-layout collapse, and say why
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:15:58 +00:00
pastilhasandClaude Opus 5 cd1f1616c4 stop editing a dashboard's name from resetting its panels
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>
2026-08-07 10:15:30 +00:00
pastilhasandClaude Opus 5 d546717676 note the unguarded screens are done too
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:10:32 +00:00
pastilhasandClaude Opus 5 653201f268 pin the app types on the four locked screens that had none
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>
2026-08-07 10:10:14 +00:00
pastilhasandClaude Opus 5 99ee9dbb88 record §5.4's first item as landed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:07:33 +00:00
pastilhasandClaude Opus 5 1de1d925a5 make the appType allow-list a prop instead of fourteen copies
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>
2026-08-07 10:06:59 +00:00
pastilhas 2c84bb34be say which shells nobody is looking at
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.
2026-08-07 09:58:04 +00:00
pastilhas c92b51cacb kill a terminal's shell when its panel is actually closed
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.
2026-08-07 09:55:50 +00:00
pastilhas 198dc71137 give panels a close signal that only fires on a real close
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.
2026-08-07 09:52:40 +00:00
pastilhas 04371a9d99 pin why a layout diff cannot answer "what closed"
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.
2026-08-07 09:49:41 +00:00
pastilhas fed2badd28 record the close of §5.9 2026-08-07 09:43:30 +00:00
pastilhas ca046a3876 build the inert context once instead of twice
`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.
2026-08-07 09:42:25 +00:00
pastilhas d3922bd9d3 stop couriering a system prompt through the framework
`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.
2026-08-07 09:40:47 +00:00
pastilhas b1e0bac1e0 record the first three items of §5.9 2026-08-07 09:36:27 +00:00
pastilhas 585f234b5b take out the props the previous commit orphaned
`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.
2026-08-07 09:35:03 +00:00
pastilhas 717580f6e4 stop the framework carrying file-browser vocabulary
`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.
2026-08-07 09:33:33 +00:00
pastilhas dbe585fd72 stop handing apps a key to reverse-engineer
`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.
2026-08-07 09:29:34 +00:00
pastilhas b1ec1e19a7 record the close of §4 2026-08-07 09:24:23 +00:00
pastilhas 92b89052a4 make a layout column refuse a non-layout 2026-08-07 09:24:16 +00:00
pastilhas 2efd7ffba3 record §5.5 and the close of tier a 2026-08-07 09:19:59 +00:00
pastilhasandClaude Opus 5 81ad3ef5ef let two windows onto the same dashboards agree again
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>
2026-08-07 09:15:55 +00:00
pastilhasandClaude Opus 5 b0a32ae473 compose layout writes against current state, not a captured tree
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>
2026-08-07 09:12:06 +00:00
pastilhasandClaude Opus 5 d24f3faa5f record §4: the boundary, validate-on-read, and the layout default
Tier A now has only §5.5 left — the resize debounce and the stale cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:09:22 +00:00
pastilhasandClaude Opus 5 ef036dfcd5 stop handing the client a layout it cannot use
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>
2026-08-07 09:08:15 +00:00
pastilhasandClaude Opus 5 64961d49f5 catch render errors instead of showing a white screen
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>
2026-08-07 09:04:03 +00:00