Commit Graph
895 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 417860b892 let a maximized panel take the whole window, header included
Maximize had one depth: fill the content region, leave the nav header visible. That was never a choice
about how much room to take — the region is an `absolute z-2` stacking context and the header is a
`fixed z-10` sibling, so no z-index a panel gives itself can paint over the nav. `top-[56px]` was the
workaround.

So full screen is cooperative rather than a bigger overlay. The panel asks, and the shell hides its own
header for it; `inset-0` is then genuinely the window. Still the same element and the same class swap —
no portal, no remount, so scroll position and playback survive the step between depths the way they
already survived maximize.

The mode rides beside the maximized panel id in sessionStorage as one value, so the two cannot drift;
a tab open across this change reads the old bare string, gets undefined for `.id`, and lands on
"nothing is maximized".

The toggle is offered from every state, so taking the window is one click from a tiled panel, and it
steps back to a maximized panel rather than all the way out. The amber light is present at both depths
and always goes all the way out, so neither is a trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:56:58 +01:00
pastilhas 9974736587 mobile-api-keys: record what the client actually does now
The client half exists in monorepo-mobile as of f29774a, with OffChat as the
proof of concept, so this document is no longer purely forward-looking. Adds a
section covering what shipped, the two places the advice here was wrong about
the client — one key per app is not possible on mobile (shared-session puts one
credential in front of all nineteen apps), and signout was never the problem,
distress signout was — and two decisions worth a second opinion: clearing the
credential on 401 only, and leaving the traded-in JWT to expire rather than
blacklisting it, because that handler clears vault tokens keyed on the user.
2026-08-08 16:26:06 +00:00
pastilhasandClaude Opus 5 b953a6ba8c agent: stop the proxy hop capping chat sessions at 200k context
Platform chat ran at 200K while the same `claude` in a terminal got Opus 5's
full 1M. Nothing to do with the model, the account or compaction tuning — the
CLI gates 1M on `provider === 'firstParty' && Fp()`, and `Fp()` is satisfied
only when ANTHROPIC_BASE_URL is unset or its host is api.anthropic.com. We
point it at 127.0.0.1:5051 so the agent's traffic goes through the OAuth
proxy, which fails that host check and silently drops the window to 200K.

_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL is the CLI's own escape hatch for a
first-party passthrough, and the assertion holds: the proxy forwards verbatim
to api.anthropic.com and already preserves anthropic-beta.

Read from the CLI binary (2.1.223), not inferred: claude-opus-5 carries
context:{window:1e6, native_1m:true}, so the model half of the gate always
passed. Only the hostname was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:24:04 +00:00
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