Commit Graph
312 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 86d979046b restore list markers in rendered markdown
tailwind's preflight resets `list-style: none` on every ul/ol, and none of the
three prose scopes put it back. the indent was there, so a bulleted list just
looked tight — but an ORDERED list rendered with no numbers at all, which reads
as the model having emitted broken markdown. pasting the same text into an
editor showed it numbered correctly, which is the tell.

the `li::marker` rules were colouring a marker that was never drawn.

fixed in .chat-md, .file-viewer-md and .skill-md, plus the inline
.markdown-preview block in MarkdownEditor, which had the same hole. nested
levels follow the usual convention (disc/circle/square, decimal/alpha/roman) and
task-list items drop the bullet, since the checkbox is already the marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:00:16 +00:00
pastilhasandClaude Opus 5 c591a8a771 make the /chat url a permalink again
reloading /chat/<id>?cwd=<dir> landed on an empty general_chat_sessions instead
of the conversation. one line did both halves of it:

  if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`)

that ran on session:init, and session:init's sessionId is officer's own
per-connection key — websocket.ts mints it as `msg.sessionId || randomUUID()`.
/chat/sessions/:id resolves a CLAUDE TRANSCRIPT uuid, so the address bar ended
up naming something no lookup could find; the detail fetch 404'd and the catch
dropped you into a blank chat. the template also had no location.search, so
?cwd= — added later, for agent grounds — was thrown away every time the socket
connected, which is why the pwd picker fell back to the default group.

the transcript uuid is only known once the turn reports it, and it only started
crossing the wire in 7b6ca5f, so move the rewrite to `result`, use
claudeSessionId, and carry the query string through untouched. new chats gain a
working permalink too — /chat/new used to become an unresolvable id the same way.

mobile back had the same query-string hole; it now keeps the search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 02:15:30 +00:00
pastilhasandClaude Opus 5 7b6ca5f4ca attribute subagent output to the task that spawned it
the harness stamps every message a subagent produces with parent_tool_use_id.
the sidecar wrote it outgoing and nothing ever read it coming back, so a
subagent's prose and tool calls were spliced into the main transcript as if the
agent you are talking to had produced them — and worse, its deltas were appended
to the same text buffer, so two voices were concatenated inside one bubble.

both buffering layers (stream-parser's textBuffer and turn-stream's buffer) are
now maps keyed by parent, and parentToolUseId rides on ChatEvent, ServerMessage
and Message. useChat nests parented output under the Task row that spawned it;
ToolActivity draws the trace inside the expanded panel.

background tasks get the same treatment from the other end: task:started and
task:notification were two unrelated fake assistant bubbles minutes apart, and
are now one role:'task' row correlated by taskId that appears pending and
resolves in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:48:21 +00:00
pastilhasandClaude Opus 5 96ceb3aca6 delete the claude-done hook and its unauthenticated endpoint
The chain was: a Stop hook in Claude's settings curls POST /api/hooks/claude-done,
the platform POSTs /_officer/panel-refresh to the pty sidecar, the sidecar sends a
`panel-refresh` frame to every attached terminal, and the Claude Code panel bumps
`preview:refresh` and `files:refresh-signal`.

It has never fired. generateClaudeSettings writes settings.json into the MANAGED
home under DATA_PATH, but HOME_DIR points terminals at the owner's real login home
— which is where Claude reads its settings from. Verified on this machine: no
claude-done hook exists in ~/.claude/settings.json, and DATA_PATH/*/home/.claude
does not exist at all.

Deleting rather than repairing it, because the Chat panel already does exactly this
job from onTurnComplete — in-process, conditioned on the turn having made tool
calls, with no hook, no HTTP round trip, and no endpoint. The chat UI is where agent
work happens; the terminal TUI is not the destination.

Also removes /api/hooks/claude-done, which was mounted above protectedRouter and so
was the one unauthenticated write-ish endpoint on the API surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:36:35 +00:00
pastilhasandClaude Opus 5 5b30aebb1d let the owner pick which chain source the wallet reads from
esplora and nbxplorer are now both selectable from wallet settings. the two are
stored as separate service_connections rows but are mutually exclusive: saving
either retires the other, so "which endpoint is in use" is never decided by a
precedence rule.

the nbxplorer probe cross-checks the chain it reports indexing against the
configured network, so pointing a mainnet wallet at a testnet node is refused at
the form rather than discovered later as an unexplained zero balance. esplora
cannot report this, so there is nothing to check there.

/_health now runs the same probe the form does, instead of its own hardcoded
esplora path — the two can no longer disagree about what a working endpoint is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:01:18 +00:00
pastilhasandClaude Opus 5 ff73e86983 move the wallet's chain source out of env and into the ui
WALLET_ESPLORA_URL was the one wallet setting an owner actually has to change — off a
public explorer that rate-limits and sees every address, onto their own indexer — and it
was the one they could only change with a shell and a restart. It now lives in
service_connections under 'esplora' and is edited at Wallet -> Settings -> Chain source,
probed against /blocks/tip/height before it is stored.

The URL joins the backend fingerprint, so re-pointing rebuilds every on-chain backend and
drops the gap-limit scan taken through the old endpoint. /_health probes what the wallets
actually use rather than the built-in default, and /_officer/config no longer reports a URL
it cannot know.

Also two receive-screen defects the Blockstream 429 exposed: a query error rendered as
"No address available", and the "new address" button called refetch() on the ?peek=true
query, so it re-fetched the same address instead of advancing the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:59:00 +00:00
pastilhasandClaude Opus 5 f45bc9292b let the wallet be renamed from settings
the PATCH route already accepted a name; nothing in the UI ever sent one. adds an
inline editor on the settings header (pencil → input, enter saves, escape cancels)
and a rename mutation. renaming touches only the label, so it needs neither the
passphrase nor an unlocked wallet.

the route took the name unvalidated — it now trims and refuses a blank one, with a
64-char cap matched on create so a name you can create is one you can type back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:44:16 +00:00
pastilhasandClaude Opus 5 d7b775113b move transmission and slskd credentials into the database
both sidecars read their upstream from a new service_connections table instead of
process.env: one row per (user, service), the secret encrypted at rest, upserted
through a /_config route the app drives. transmission gains a Connection section,
soulseek gains one too, and both take over the whole app while nothing is stored.

TRANSMISSION_URL/USER/PASS/RPC_PATH and SLSKD_URL/API_KEY can come out of .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:04:22 +00:00
pastilhasandClaude Opus 5 f20d4a300e invoiceshelf: accounts are configured from the ui, not the environment
The same registry photos got: any number of labelled instances stored encrypted in
invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across
the sidecar boundary — the list has no field that could carry it back — and nothing reads
INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a
credential only the sidecar can use.

The company is pinned on the account row rather than resolved per request. InvoiceShelf's
`company` header does not error on a wrong or missing value; it silently returns another
company's books. So the choice is made once, at add time, and a token that can act for several
answers 409 with the list instead of guessing.

Both apps also take an email and password now, because neither service makes a key easy to get:
InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way),
and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a
Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again —
and stores only what comes back. The password is never persisted. Pasting a key still works.

Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read
from the running containers. The two sign-in paths are untested end to end — no second login to
try them with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:44:28 +00:00
pastilhasandClaude Opus 5 632d5a1c1f photos: immich accounts are configured from the ui, not the environment
IMMICH_URL/IMMICH_API_KEY lived in the platform-wide .env, which was wrong
twice over: bun auto-loads .env into every process started in this directory,
so `officer` itself held an immich credential it has no code to use — and
connecting a library was a shell task on the server rather than something the
owner could do from the app.

it is a registry, not a single connection: any number of labelled accounts with
one selected, the same shape headscale_servers uses. two keys against the same
instance (one per immich user) is the ordinary case, so the label is what has to
be unique, not the url. one active account per owner is enforced by a partial
unique index rather than by convention.

keys are encrypted at rest and write-only across the sidecar boundary — no route
returns one, masked or otherwise. every save is validated against the live
instance first, so a wrong or under-scoped key is a 400 with the reason instead
of a stored row that makes every later screen fail mysteriously.

the drizzle snapshot under migrations/ is regenerated; nothing applies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:05:58 +00:00
pastilhasandClaude Opus 5 c77e7ee598 photos: inspect the pictures behind a map cluster
clicking a cluster only zoomed, so a circle marked 700 was unopenable at any
zoom level that still grouped them. it now opens a lazy list of exactly the
assets that cluster covers, independent of zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:05:49 +00:00
pastilhasandClaude Opus 5 cc95f7fa17 run an agent from the file browser
Right-click an entry and agents whose triggers match appear under their own Run Agent
submenu — deliberately not folded in with tasks, because an agent run is a chat session and
not a job, and one menu promising both would lie about what a click does. The modal shows the
absolute target path, autofills entry_path with it (tilde expansion stays the server's job),
and on Run links to /chat?cwd=<runs dir> instead of a queue entry: there is no job row to view.

Trigger matching and category grouping are now shared with tasks rather than duplicated, and
the task input form is reused as-is.

Rescan counts agents and invalidates their caches, so a new AGENT.md shows up on the button
rather than after the 60s staleTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:28:51 +00:00
pastilhasandClaude Opus 5 6b46d9f1f9 chat: put the session list cwd in the url, not a panel channel
Which project group you are looking at is addressable state, so /chat?cwd=<dir> has to be a
link anyone can hand out — it survives a refresh and an agent run can point straight at its
own runs directory. Was usePanelChannel('chat:active-cwd'), which per the navigation audit is
for signals and refresh buses only. Row links and New Chat now carry the query string along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:28:44 +00:00
pastilhasandClaude Opus 5 035a1ba8f6 add photos, an immich-backed library behind its own sidecar
officer-photos owns the whole Immich contract: the instance URL and the API
key live there and nowhere else, and the platform side is an auth-gated
forwarder holding no credentials. The route surface is an allow-list keyed on
the first path segment, so admin, auth, api-keys, sessions, jobs, system-config
and libraries are unreachable by construction rather than by enumeration.

The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums,
people, favorites, sharing, archive, trash — because the point of a sidecar
screen is to reproduce what the upstream already ships, then extend it. The
timeline reads Immich's columnar time-bucket format directly; selection lives
in the URL per docs/navigation-audit.md.

Two things worth knowing for anyone touching this later:

- `duration` is an integer count of milliseconds in Immich 3.0. It was an
  HH:MM:SS.mmm string before, and every stale example still shows that form.
- the map container is sized with h-full/w-full, never `absolute inset-0`.
  maplibre's stylesheet sets `position: relative; overflow: hidden` on the
  element it is given, and an unlayered vendor rule beats Tailwind 4's layered
  `.absolute` regardless of source order — so the div collapses to height 0 and
  clips its own canvas away. Nothing errors: the GL context is healthy, tiles
  download and pixels are drawn into a buffer nobody ever composites.

maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file
from import.meta.url, which Officer's index.html fallback answers with HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:18:33 +00:00
pastilhasandClaude Opus 5 25da4dc31d vnc: back to mirroring the physical screen, at 1920x1080
Reverts the Xvnc virtual-desktop work (83bf746, f22c667). The separate desktop was the right answer to
"4K on the TV and 1080p remote", but it brought a chain of its own problems — a dock left stranded
below the bottom of the screen after every resize, because xfce4-panel does not follow a RandR change
reliably — and the owner would rather have one session that works than two that need supervision.

So: one GNOME session, mirrored, with the TV set to 1920x1080. That is under SCALE_ABOVE_WIDTH, so
x11vnc serves it 1:1 with no scaling, and both ends see the same 1920x1080 desktop.

resizeSession is now off on the client — a mirror reflects a physical screen and cannot be resized.
scaleViewport stays ON and is load-bearing: noVNC maps a click as
(clientX - canvasRect.left) / display._scale, and autoscale() is the only code that sets the canvas's
displayed size and _scale in the same call. Disabling it pins _scale at 1 while the canvas is displayed
at some other size, which doubled every pointer coordinate. That was my change and my bug.

Kept from the Xvnc detour, because all of it applies to the mirror too: the display is discovered by
socket ownership rather than assuming :0 (GDM gives :0 to its greeter), -noxdamage is gone, the clip to
the primary output stays, noVNC loads as one bundle, and showDotCursor covers the invisible pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:35:22 +00:00
pastilhasandClaude Opus 5 5ba89ae74d desktop: restore scaleViewport — disabling it doubled every pointer coordinate
Reverts the scaleViewport=false half of 69c750d. That change was wrong and it caused a worse bug than
the one it was aimed at.

noVNC maps a click as (clientX - canvasRect.left) / display._scale. autoscale(), which only runs when
scaleViewport is enabled, sets the canvas's displayed size and _scale in the same call, so the two are
consistent by construction. With scaleViewport off, _scale is pinned at 1 while the canvas continues to
be displayed at some other size and nothing reconciles them.

Measured on the owner's session against a 1109x715 desktop: pointing a quarter of the way across
registered at x=575 (51%), and pointing at the middle saturated at x=1108, the right-hand edge. A clean
factor of two in both axes. Clicks near the right and bottom edges still appeared to work, because
doubling an already-large coordinate clamps back onto the edge it was aimed at — which is why the
panel clock and the dock stayed clickable while the middle of the screen did not.

My reasoning for the original change — that resizeSession and scaleViewport are alternatives — was
simply wrong. They are complementary: resize matches the session to the container, scaling covers the
interval before the server honours it, and scaling is also what keeps the coordinate maths honest.

showDotCursor stays: the invisible pointer was a real and separate defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:20:36 +00:00
pastilhasandClaude Opus 5 4cf39a6ed3 desktop: show a dot cursor when the server sends no cursor shape
The pointer was invisible, not misplaced. noVNC hides the browser's own cursor over the canvas and
draws the remote cursor in its place, but initialises that image to RFB.cursors.none — so until the
server sends a shape, nothing is drawn AND the real cursor stays hidden. The pointer simply vanishes.

Measured before changing anything: against a 1109x715 session the pointer covered x 5..1108 and
y 11..714, a clean 1:1 map with no clamping or scaling. Clicks were landing exactly where they were
aimed the whole time; the cursor just could not be seen, which is indistinguishable from a desync
when you are the one trying to click something.

showDotCursor renders a dot in exactly that case. It does not mask a real problem: when the server
does supply a shape, the shape still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:19:56 +00:00
pastilhasandClaude Opus 5 69c750dd72 desktop: resizeSession without scaleViewport, so the cursor lands where you click
Both were enabled. They are alternative strategies, not complementary ones: resizeSession asks the
server to become the container's size, scaleViewport scales whatever the server sends to fit. Running
both means noVNC resizes AND then applies a scale factor, and any gap between the size requested and
the size actually granted leaves a fractional scale that every pointer coordinate is mapped through.
The visible symptom was a cursor offset from the real pointer, with clicks landing somewhere else.

It only started mattering with the move to Xvnc. x11vnc could not resize, so resizeSession was inert
and scaleViewport did all the work; Xvnc implements RandR SetDesktopSize, so now both are live and
they interfere.

Scaling is redundant now the session genuinely becomes the container size: pixels are 1:1 and there
is no coordinate arithmetic left to get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:13:32 +00:00
pastilhasandClaude Opus 5 51b249b90a desktop: load noVNC as one bundle instead of 42 modules
public/novnc is unbundled noVNC source. The dynamic import pointed at rfb.js, so the browser walked
the module graph natively: fetch a file, parse it, discover its imports, fetch those, repeat. The
graph is 42 modules and six levels deep, so opening the desktop cost six SEQUENTIAL round trips and
42 requests before the VNC handshake could even start — and a hard refresh pays it in full every
time. Over the tailnet that is the "takes a long time to load", not the pixels.

Bundled with bun: 52 modules to one 190 KB file, 56 KB gzipped, one request. The regeneration
command is in a comment next to the constant so a future noVNC bump does not silently keep serving
a stale bundle.

The source tree stays: it is what gets bundled, and keeping it makes the diff of a version bump
readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:52:50 +00:00
pastilhasandClaude Opus 5 7d5e73cba8 soulseek: let a filtered result expand back to its whole folder
Filtering a big search is how you find an album, not just how you hide
files: you type one track you know, and the folder that comes back is the
one you want. But the filter had also stripped that folder down to the one
track, and "download folder" then queued only that track — so finding the
album and losing it were the same act.

A folder now keeps its whole self alongside its matching files, and says
"3 of 24". Expanding shows the album, keeps the matched track highlighted,
and widens every download button in it to the full folder. A peer whose
other folders matched nothing can be expanded the same way, since the album
you found usually sits next to the rest of the artist.

Nothing queues what isn't on screen: each button downloads exactly what is
shown under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:06:02 +00:00
pastilhasandClaude Opus 5 93bf617151 soulseek: open a search's results when you start it
A history row is only as fresh as the last list load, and the list was only
read on mount — so a search you had just started sat at 0 responses looking
stalled until you navigated away and back. slskd's own web UI goes straight
to the search when you submit, and that view already polls while the search
runs, so follow it.

Which search is open now lives in ?search=<id> rather than local state: rows
are real links, Back returns to the history, and a reload keeps your place.
The list also keeps re-reading while any search is still running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:51:23 +00:00
pastilhasandClaude Opus 5 82290bb3e0 add /qr-transfer — offline file transfer over animated QR
TEMPORARY / EXPERIMENTAL, at the owner's request, after deedy/qr-data-transfer (QRFerry).
Two panels: one loops a file as QR frames, the other scans them through the camera and
rebuilds it. Entirely client-side — nothing about a transfer reaches the server, which is
the point of the technique.

NOT RaptorQ, and that is the one real design decision here. QRFerry carries RFC 6330
fountain-coded symbols so a receiver can rebuild from ANY sufficient set of frames. This
uses a plain indexed carousel instead, for two reasons — the second being the deciding one:

  1. RFC 6330 is days of work and unpleasant to debug.
  2. The sender and receiver are being reimplemented on iOS and Android. A format one person
     can re-derive from protocol.ts in an afternoon is worth more here than optical
     efficiency. Every frame is independent, self-describing, and parses with a string split.

The cost is honest and written down: without fountain coding you must eventually capture each
specific frame, so a miss waits for the next pass rather than being covered by surplus. Fine
for a few hundred KB on a steady camera; it degrades where RaptorQ would start to pay for
itself.

Details that matter for the phone implementations:
- base64url, no padding — ':' and '/' would collide with the field separator.
- CRC-32 of the whole file in the meta frame, checked after reassembly. The test pins the
  reference value for "123456789" (cbf43926) so any stock implementation will agree.
- The meta frame repeats every 12 frames, so a receiver joining late learns the filename and
  total without waiting a full cycle.
- Error correction level L: frames are short-lived and repeated forever, so QR capacity is
  better spent staying sparse enough to scan than on recovery.

16 tests over the protocol, which is the spec the other implementations should match.
The receiver needs a secure origin for camera access; over the tailnet with HTTPS that holds,
and it says so plainly rather than failing silently on plain http.

Adds qrcode and jsqr. @types/qrcode was already present and orphaned — its runtime package had
been removed with the chat-channel cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 02:17:20 +00:00
pastilhasandClaude Opus 5 a83f9cd378 delete the orphaned build script, and fix the second copy of the memo rule
scripts/build/runtime.ts had no caller after the build:editor scripts went. Its own usage
text gives away where it came from: --experiments, --tracking, --editor-setup, the same
phantom domain as the docs and examples cleaned up earlier. Nothing else references it —
the `./runtime` export in src/workspaces/types/package.json points at a different file,
which is untouched. helpers.ts stays; dashboard.ts and web.ts import it.

APP_CONVENTIONS.md carried its own copy of "React 19's compiler handles memoization. Never
use useCallback or useMemo." Correcting only CONVENTIONS.md would have left the two
contradicting each other, which is worse than either. Both now say the same thing and one
points at the other for the reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:12:19 +00:00
pastilhasandClaude Opus 5 2eda855551 persist the wallet's view of the chain
Opening a wallet meant waiting for a full gap-limit scan before any number appeared, and a
restart threw that work away. Worse, an unreachable Esplora rendered identically to an empty
wallet — as a zero balance — which is alarming for the one case where it is not true.

The backend now keeps a snapshot and serves it stale-while-revalidate: a snapshot inside the
TTL is served as-is, an older one is served immediately with a refresh started behind it, and
only a wallet that has genuinely never been read blocks on the network. wallet_chain_cache
holds one row per wallet so a refresh is a single atomic upsert.

The snapshot, not the endpoint, is the unit of caching. Balances, UTXOs and history were
three fetches over a shared scan, so the three queries a wallet screen fires on mount could
each observe a different moment; building them together costs the same requests and fixes
that incidentally.

Only the chain's own facts are stored. Addresses, scripts and pubkeys are re-derived from the
account xpub on load — cheaper than persisting them, and it means a restored snapshot cannot
disagree with the wallet's actual keys. Stored coordinates are validated rather than trusted,
and a snapshot at an unknown version is discarded, not migrated.

Two reads deliberately opt out. sendCoins takes a fresh snapshot because selecting coins from
a cached UTXO set builds a transaction spending outputs that may already be gone, and that
failure arrives as a broadcast rejection after signing. nextUnused does too, because handing
out an address whose stale record says "unused" is silent address reuse — a privacy leak the
owner cannot see or undo. Receive-address generation is therefore the one read that stops
working while the upstream is down, on purpose.

Failures are recorded alongside the last good snapshot rather than replacing it; wiping data
on failure would reproduce the exact bug this exists to fix. Every cache operation is
best-effort, so a database problem degrades to a slow load and can never fail a wallet
request. A sync block on balances, transactions and utxos carries the age to the UI, which
now distinguishes "empty" from "never read".

Verified against three live mainnet wallets: snapshots persisted and reloaded, and two
wallets kept their data and age through a real Esplora rate-limit failure while recording the
error separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:12:57 +00:00
pastilhasandClaude Opus 5 2647510965 import lnd aezeed cipher seeds alongside bip39
An LND seed is not a BIP39 mnemonic. It shares the 24-word shape and the English wordlist,
which is exactly why it fails confusingly: the words validate as plausible input, the
checksum does not, and the user is told their own seed is invalid.

aezeed is a different construction — a 19-byte payload (version, birthday, 16-byte entropy)
sealed with AEZ under scrypt(passphrase, salt), with the salt carried in the mnemonic
itself. So it needs its own decipher, not a flag on the BIP39 path. The vendored aez/aezeed
implementation under sidecar/wallet/aezeed does that, and the recovered entropy becomes the
BIP32 root the same way BIP39 output does.

The seed envelope gains a kind ('bip39' | 'aezeed') so an unlock knows which derivation to
run rather than guessing from word count, which cannot distinguish them.

Also note the aezeed passphrase is not a BIP39 passphrase: it decrypts the seed rather than
salting the derivation, so a wrong one fails the checksum outright instead of silently
producing a different wallet. The UI can therefore tell the user they typed it wrong, which
is not possible for BIP39.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:12:35 +00:00
pastilhasandClaude Opus 5 fccf212fe5 tmux that actually persists, and a running-shells panel
The Tmux panel typed bare `tmux`, which starts a NEW session every time — so the panel
forgot your windows whenever the shell behind it went away, including on a
`pm2 restart officer-pty`. It now runs `new-session -A -s <name>`, which attaches if the
session exists and creates it otherwise, named per panel from panelId (stable in the saved
layout). The tmux server outlives the pty, so this survives what a pty session cannot.

CommandTerminalWrapper had the same unmount bug TerminalWrapper did — it deleted the panel
-> session mapping on unmount, so every layout change abandoned the shell AND re-ran the
command from scratch. Kept across unmounts now, same as the plain terminal.

Two things the seeded .tmux.conf needs that were missing:

- COLORTERM=truecolor in the pty env. TERM only advertises 256 colours, and COLORTERM is
  what programs check before emitting 24-bit — so we were throwing away colour depth for
  tmux, neovim, bat, delta and any modern TUI. xterm.js renders it fine.
- macOptionIsMeta. On macOS Option is a compose key, so Alt bindings never reach the shell
  — silently breaking the M-arrow and M-hjkl pane switching the config leans on. No effect
  on other platforms. Also rightClickSelectsWord, so right-click stops opening the browser
  menu over the terminal.

Adds a Running Shells panel: every shell the sidecar holds, with the title it set for
itself (usually the running command), pid, size, idle and uptime, and a kill button.
That is the other half of keeping session mappings across unmounts — the leak stops being
invisible, and stops needing curl to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:40:11 +00:00
pastilhasandClaude Opus 5 875f240e1c terminals: re-attach on reopen, and make orphaned shells findable
Closing a terminal panel abandoned its shell. TerminalWrapper deleted the panel -> session
mapping on *unmount*, so any layout or route change generated a fresh uuid on the way back
and left the old shell running: alive, unreachable, and never killed, because nothing has
ever sent pty:close. The mapping now outlives the mount, so reopening a panel re-attaches
to the shell you left — which is also what finally makes the sidecar's replay buffer worth
having. It is persisted dashboard state, so this survives a reload too.

That trades an invisible leak for a visible one: a panel deleted for good still leaves its
shell behind. So `pty:list` now enumerates live sessions, and GET /api/terminal/sessions +
DELETE /api/terminal/sessions/:id expose them. pty:close finally has a sender.

Each session carries createdAt, lastActivityAt, pid, and the title the shell sets for
itself via OSC 0/2 — usually the running command, which is what turns "some uuid" into
"the one running claude" when you are deciding what to kill.

Killing on unmount is still not an option: it needs the panel system to distinguish a real
close from an incidental remount, which it cannot currently do.

Also raises the sidecar replay buffer from 50KB to 512KB — 50KB was about one long agent
turn, so reconnecting mid-task showed you the tail and nothing before it — and cuts the
buffer on a line boundary rather than a byte offset. A blind slice can land inside an
escape sequence, and the replay then opens with the tail of a colour or cursor-move code,
which xterm renders as garbage or applies as a real instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:30:02 +00:00
pastilhasandClaude Opus 5 dfb20a734e terminals: resize with the panel, and five xterm addons
The terminal was xterm with one addon (fit) and a single fit() call at connect, so the
shell kept whatever cols/rows it was born with. Drag a panel wider and the PTY never
heard about it — anything drawing a full screen (an agent TUI, top, vim) rendered into a
box that no longer matched the one you were looking at. A ResizeObserver now refits and
sends pty:resize, coalesced to one fit per frame because dragging an edge fires
continuously and each fit reflows.

Scrollback goes from xterm's default 1000 lines to 10,000 — one long agent turn was
enough to lose the start of it.

Addons, all off the shelf and none previously loaded:

- webgl — the renderer, and the reason fast repainting feels smooth rather than syrupy.
  Guarded twice: construction can throw where there is no GL context, and the context can
  be lost later, so both paths fall back to the DOM renderer instead of a dead canvas.
- unicode11 (+ allowProposedApi) — correct widths for emoji, CJK and box-drawing. The
  default unicode 6 tables are why agent TUI frames sit a column out.
- web-links — URLs in output are clickable.
- search — with a find bar on Ctrl/Cmd-Shift-F. Not plain Ctrl-F: that is forward-one-char
  in readline and emacs, and swallowing it would break every shell in the app.
- serialize — installed for exact-state replay, not yet wired.

Also reports the shell's own title (OSC 0/2) and the bell through new optional callbacks,
so a panel can show what is running in it and flag a finished turn you weren't watching.
Nothing consumes them yet.

Unrelated but found by this run: three origin-validation tests were failing. Not from this
change — ALLOW_ANY_ORIGIN=true in .env, added last night, and Bun loads the host .env into
tests, so the kill switch had silently turned the assertions into no-ops. The test now pins
both escape hatches off, since its whole job is asserting the checks reject things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:26:57 +00:00
pastilhas 188b45c113 remove projects and apps end to end
deletes the last of the projects/apps cluster: the published-app store
(/api/apps + /api/app-serve), the project dev-server and its websocket
proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite
they were the only consumers of, and their frontend — the Preview panel,
the UserApp panel/header, useUserApps and the /settings/apps screen.

also drops getUserProjectsDir and getUserAppsDir, the ProjectType and
ProjectDefinition types, and the 'dev-server' websocket provider from
server.tsx. nothing on disk is touched.

1440 deletions, 31 insertions. tsgo clean.
2026-07-31 07:39:29 +00:00
pastilhasandClaude Opus 5 f8826e4c24 add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.

officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.

no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.

not reviewed line by line; assembled and verified to build, not audited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 06:48:06 +00:00
pastilhasandClaude Opus 5 5ee56e736b add the invoices ui over the invoiceshelf sidecar
a /invoices workspace screen replicating the parts of the invoiceshelf web ui
that are actually used: dashboard, invoices, estimates, recurring invoices,
payments, expenses, customers, items and reports.

one document editor serves invoices, estimates and recurring invoices — they
share the line-item table, the discounts, the taxes and the totals, and differ
only in a header strip. the arithmetic is a port of upstream's
use-document-calculations, rounding points included, because the server
re-validates the totals it is sent. money is integer minor units throughout and
is converted to major units at the edges only.

selection and editor state live in the url (`/invoices/:section`, `?selected=`,
`?edit=`), so the nav highlight is derived rather than held, back closes an
editor instead of leaving the screen, and a half-written form survives a reload.

sending a document is behind an explicit confirmation — it emails the customer.

built against the running 2.4.2 instance rather than the 3.0 checkout in
_references; the two differ materially. typechecked, not yet exercised in a
browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 06:44:02 +00:00
pastilhasandClaude Opus 5 387664964c add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.

/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.

phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:09:19 +00:00
pastilhasandClaude Opus 5 17522be71a drive the headscale section from the url
sections were held in a usePanelChannel, which is exactly the opaque
click the navigation audit catalogues: the id lived in an onClick
closure, so a section could not be linked to, opened in a new tab or
reached with the back button.

/headscale/:section is now the source of truth. nav items are real
NavLinks with the active class coming from the router rather than
derived in js, and the screen redirects bare or unknown sections to a
canonical url so the highlight always matches the address bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:36:39 +00:00
pastilhasandClaude Opus 5 62dfc572d0 show id and hostname in the node list heading
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:27:59 +00:00
pastilhasandClaude Opus 5 8029e506df probe the active headscale server automatically
the dot showed amber for the active server until you pressed test,
which read as a warning while every other section on the screen was
loading fine. probe the active one on mount so the dot always states
something checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:24:49 +00:00
pastilhasandClaude Opus 5 0d227c46a3 headscale nodes, users and pre-auth key management
domain routes in the sidecar: nodes with per-route approval done as a
read-modify-write (headscale's approve_routes replaces the whole set),
users enriched with node counts, and pre-auth keys.

pre-auth key secrets are revealed by call path, not by inspecting the
value. headscale masks keys created since 0.28, but returns older
plaintext ones in full from the list endpoint for backwards
compatibility, so listing would otherwise ship live secrets into the
browser's query cache. the list always nulls the secret; only creation
reveals it, and the ui shows it once with a copy affordance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:08:02 +00:00
pastilhasandClaude Opus 5 adf922de30 add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and
their admin api keys, the >=0.29 version floor, and every multi-call composition
the ui needs. the platform side is auth+forward only and holds no headscale
credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars
are untouched and unrelated.

officer manages many servers rather than one. the owner registers each with a url
and a key generated on that server and switches between them; exactly one is
active, enforced by a partial unique index rather than by convention. keys are
encrypted at rest and never leave the sidecar — the list projection cannot return
one. registration validates before it saves: an unauthenticated GET /version to
prove something headscale-shaped is there and meets the floor, then an
authenticated call to prove the key works. an edit that moves either half
re-validates.

there is deliberately no transparent /api/v1/* passthrough. headscale serialises
every uint64 as a json string and its rest shape moved repeatedly below 0.29;
proxying raw would push all of that into the browser, which is the mistake the
soulseek panels made with 37 raw upstream calls.

the /headscale workspace is nav + view over the panel system. only the servers
section is implemented — nodes, users and pre-auth keys say so plainly rather
than rendering an empty table that reads as a failed fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:55:32 +00:00
pastilhasandClaude Opus 5 5a1e3d7e0b remove the projects feature
Being retired, so it is deleted rather than fixed — it had both defects dashboards just
had (edit dropping ?selected=, an abandoned edit following you onto the next item) and
repairing them was work for something on its way out.

Gone: the two screens and the panel apps, the three routes, the dock item and its entry
in the default dock paths (client and the three server-side copies), the page-title rule,
the app-registry entries, the barrel exports, the `projects` table with its queries and
row types, and the proj-meta/proj-layout/proj-terminals/proj-host-terminals branches in
the dashboards endpoint. The chat context and terminal state-key special cases for
`proj-layout-` went with them.

Deliberately kept: `getUserProjectsDir` in data-path.ts — the apps and dev-server routers
resolve user apps under the same on-disk Projects/ directory and are unrelated to this
feature. Nothing on disk is touched.

Needs `bun db:push` to drop the table; the schema change is the only thing standing
between the code and the database. One row was in it.

tsgo clean, 56/56 tests, no references left in src. Not yet exercised at runtime — the
restart is what will prove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:16:27 +00:00
pastilhasandClaude Opus 5 4970e7e70f dashboards: keep the selection while editing, and let the form win
Clicking edit dropped ?selected= from the url, so the address bar forgot which dashboard
you were editing — and on mobile that is worse than cosmetic: DashboardsScreen uses
`selected` to decide whether to show the right-hand panel, which is where the form
renders, so tapping edit opened it out of sight.

It cleared the param because it had to. The form was only reachable through
DashboardPreviewEmpty, which the preview renders when nothing is selected, so an edit
could only be shown by first pretending nothing was selected. DashboardPreview now checks
creating/editing itself, before it resolves the selection, and the empty state loses its
duplicate of that check — having two of them is what made the first attempt at this look
safe when it removed the only route to the form.

The form is shown only for the dashboard actually selected (`editingId === selectedId`),
and picking a row clears creating/editing. Without both, an abandoned edit followed you
onto the next dashboard: the url said you were on B while the form still edited A. The
click handler covers the ordinary path and the guard covers back/forward and pasted
links.

Verified in the browser: D5, D7, D8, D9 — edit keeps the param and opens the form, save
hands back to the preview of what you edited, switching dashboards mid-edit abandons it
cleanly, and returning to the first one shows its preview rather than the stale form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:00:14 +00:00
pastilhasandClaude Opus 5 4e16a874dd merge the sidecar isolation work
Brings in the five per-sidecar branches that were integrated on `sidecars`: the agent
split out of officer's process tree (officer-anthropic-proxy + officer-agent as PM2
peers), turn output translated and committed by the sidecar that produced it for both
claude and opencode, the pty reconnect fix and its move under sidecar/, the vnc
password read and desktop reattach, the cliamp audio pipeline moved into the music
sidecar, and the email folder-filter SQL injection.

Verified live before merging: all ten PM2 entrypoints start clean, the agent resolves
the owner from the database and the proxy secret from disk, and a real turn round-trips
through the new path — the sidecar writes chat_session_events itself and `prevSeq`
chains correctly across messages.

One conflict, docs/navigation-audit.md (add/add): the copy on `sidecars` was an earlier
snapshot swept in by 62dc4c1, and master's is the same document with H1-H3 marked done.
Took master's wholesale — nothing was lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:29:04 +00:00
pastilhas 9318fe8d0e Merge branch 'sidecars-claude' into sidecars 2026-07-30 05:43:05 +00:00
pastilhas 5efffa9dcb Merge branch 'sidecars-vnc' into sidecars 2026-07-30 05:43:01 +00:00
pastilhasandClaude Opus 4.8 63f45de869 projects: drive list selection from a ?selected= url param, not a global
mirror of the dashboards change: the /projects list row was a <div onClick> setting
SELECTED_PROJECT (a global) with no url change on-page. selection now lives in the url
as ?selected=<id>, read by the list, the screen (mobile panel), and the preview. rows
are real <Link>s (publish/edit/delete kept as sibling buttons); edit-save and delete
update/clear the param; NewProjectRedirect drops its now-redundant setSelected.

NOT runtime-tested yet (server not restarted) — verify /projects preview,
create/edit/delete/publish, and mobile panel flows on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:35:24 +00:00
pastilhasandClaude Opus 4.8 1b6b980af0 dashboards: drive list selection from a ?selected= url param, not a global
the /dashboards list row was a <div onClick> that set SELECTED_DASHBOARD_KEY (a
global) with no url change on-page, so selection wasn't deep-linkable and the row
wasn't a real link. selection now lives in the url as ?selected=<id>, read by the
list, the screen (mobile panel), and the preview. rows are real <Link>s (edit/delete
kept as sibling buttons, not nested in the anchor); the preview + its "open" link are
unchanged. edit-save and delete update/clear the param.

NOT runtime-tested yet (server not restarted) — verify the /dashboards preview,
create/edit/delete, and mobile panel flows on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:32:10 +00:00
pastilhasandClaude Opus 4.8 1014739b1b reattach the desktop when officer restarts under it
x11vnc mirrors :0 with `-forever`, so the desktop survives officer going away —
only the bridge socket dies. The panel treated that as the end of the session:
"Disconnected from desktop" until someone closed and reopened it. Retry with
backoff instead, and cover the password fetch too, since that is what fails
first while officer is still coming up.

Retries are generation-guarded so tearing an instance down cannot be mistaken
for a lost connection, and a security failure or a missing noVNC is terminal —
those do not come back on their own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:11:19 +00:00
pastilhasandClaude Opus 4.8 b92e8dffad keep terminals alive when officer restarts under them
the pty sidecar registered `term.onData` with the socket that happened to be live
when the session was created. officer is a pm2 peer that restarts constantly, and
every restart hands this process a brand new socket, so every pre-existing session
went on writing to a closed one — where sendJson's readyState check dropped it
silently. the shell survived and still accepted input, because input arrives on the
new socket, but nothing ever came back. you typed and the terminal sat there. the
only way out was to close the panel, which orphaned the shell.

sendJson now reads the module-level socket at send time instead of taking one as an
argument, so there is no socket to capture and go stale. that is the whole fix.

the scrollback replay on re-attach becomes its own event, pty:replay -> 'replay'
on the browser socket. it used to arrive as ordinary output, which was fine for a
page load (fresh xterm) but not for a restart: the browser keeps its terminal, so
replaying blind printed a second copy of everything still on screen. marked as
history, Terminal.tsx resets and rebuilds from the sidecar's 50KB buffer instead.
it also stays out of the `output` branch so it cannot re-trigger the command /
initial-input logic that scrapes output for a sentinel.

added an integration test, because this is a reconnect bug and nothing short of an
actual reconnect proves it: it stands up a fake registration socket, runs the real
sidecar against it, echoes into a real shell, kills the socket, rebinds the same
port the way pm2 does, and asserts output still flows. verified it fails against
the old sendJson (times out after 15s waiting for the post-restart echo) and
passes in ~400ms with the fix. it never touches the running officer — the sidecar
dials API_URL, overridden per spawn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:59:06 +00:00
pastilhasandClaude Opus 4.8 238c3b8097 make the agent sidecar the writer of record for chat output
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.

move the translation and the write into the sidecar:

- turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out
  of websocket.ts (delta buffering, flush before tool:start and result). pure and
  synchronous, so it is unit tested — 12 tests, 100% lines.
- session-log.ts commits each message to chat_session_events and only then hands it
  to officer, with its cursor id attached. per-session promise chain: translation is
  synchronous and therefore in arrival order, and only the commit is queued, so
  cursor ids are assigned in the order events actually happened. a delta that
  overtook the assistant:text in front of it would make the client commit its stream
  buffer at the wrong point, so deltas go through the same queue even though they are
  never written.
- claude:event on the wire becomes claude:message: a finished browser-facing message
  plus its seq. officer relays it verbatim and folds it into the in-memory session
  for sync:messages. it no longer builds or persists chat messages for this harness.

gap detection, which is what the durable log is for. chat_session_events.id is a
global bigserial, so two consecutive events of one session are not consecutive ids
and a client cannot tell a contiguous replay from one with a hole in it. each durable
message now carries prevSeq — the cursor of the previous message in the same session —
which is inside the persisted payload, so it survives replay. useChat compares it
against the cursor it holds before advancing, and surfaces a visible marker on a
mismatch: a conversation that silently skips a tool call or half an answer reads as
the assistant having done something inexplicable. only checked once a cursor exists,
because opening a session from history legitimately starts mid-chain (events are swept
after 7 days, the transcript is not).

a failed write delivers live with no seq, so the client sees the message but does not
advance past something it cannot replay, and the next successful write chains from the
cursor the client still holds.

pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser
will ever replay and the job's own event log is its record, so writing those rows only
grows the table.

opencode still goes through officer's createEventHandler, now labelled as such. that
is the sidecars-opencode branch.

this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:51:52 +00:00
pastilhasandClaude Opus 4.8 b27dd7512b load chat transcripts as a tail window and lazy-load older on scroll-up
extremely long transcripts made bottom-anchoring the virtualized list unreliable
(thousands of unmeasured variable-height items = a huge estimate the scroll never
lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed
slice plus total+offset. the chat opens on the last 20 messages, anchors to the
bottom instantly, and scrolling near the top pages in the next older window,
prepending it and pinning the previously-top message so the view stays put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:49:13 +00:00
pastilhasandClaude Opus 4.8 3682269936 resolve /chat/<id> by scanning project groups and make session rows real links
deep-linking or refreshing /chat/<id> only has the id, so add loadClaudeSessionById
to scan every project group for the transcript and return its real cwd. the session
list page resolves on load from that, setting the cwd picker and resuming. session
rows are now <Link to=/chat/<id>> so clicking updates the url and flows through the
same resolve path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:35:47 +00:00
pastilhasandClaude Opus 4.8 eca1f83162 workspace: maximize a panel without remounting its content
Maximize was swapping the app into a createPortal(document.body) branch, so React
unmounted + remounted it — losing scroll, playback, and in-flight state on every
maximize/restore. Now it's a pure CSS state toggle on the same element (fixed,
filling the content region), so the app instance is preserved and content stays
exactly as it was. The panel is confined to the layout's z-2 content stacking
context (under the fixed nav Header at z-10), so the maximized box starts below
the header — keeping the panel's own header/Restore visible and the nav reachable.
Dropped the portal + placeholder and the now-unused createPortal import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:38:05 +00:00