Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 e9b71522ed release 2026.08.29.04
The file preview becomes a carousel, zoom starts working again on every plugin
screen, and a platform event bus arrives with thumbnails warmed on arrival.

No schema change and no dependency change — verified against the range rather
than assumed, and the plan says so explicitly so neither step gets improvised.

Six of the seven hosts sit exactly on the previous tag. fofos does not: it has
been hand-pulled all day while this work was written against it, and its commit
is an ancestor of the tag, so it fast-forwards like any other host with fewer
commits to travel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:20:27 +01:00
pastilhasandClaude Opus 5 73a9b0bf51 officer blocks its event loop re-parsing a live 196 MB transcript
Chasing "Minesweeper is laggy" ended somewhere else entirely. Press-to-paint is
29ms median at x11vnc's socket and 29ms through the sidecar relay, but 35-200ms+
in the browser — so every bit of the variance is added by the one hop between,
which is officer.

Officer answering a trivial 401 on /api/health: median 1ms, p90 2ms, max 490ms.
summarizeTranscript full-reads and JSON.parses an entire transcript to build a
session-list row; timed against the largest here (196 MB, 70,137 entries) that
is 323ms to read plus 541ms to parse — 864ms of blocked loop per call. It is
cached on mtime, and a LIVE session appends every few seconds, so the cache
misses exactly when the agent is busy.

Filed rather than fixed: it is a hot path shared by every surface, and the right
shape (incremental parse keyed on size, versus moving summaries off the request
path) is an architecture call. The file already contains the argument for the
incremental version — "Claude only ever appends".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-29 13:10:20 +01:00
pastilhasandClaude Fable 5 882cb67f7a todo: openseo as a plugin candidate, checked against the repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 13:06:00 +01:00
pastilhasandClaude Opus 5 2738f799f2 zoom is the user's, so an authored layout has to graft it back
`useAuthoredLayout` keeps the author's structure and re-applies only what the
user owns. It knew about sizes and nothing else, so `zoom` — which lives on the
panel node — was rebuilt from source on every render and lost.

The failure was invisible rather than merely unsaved. `setValue` re-derived a
tree identical to the one it already held, the no-write guard dropped it as a
no-op, and the header went on reading 100%. Every plugin screen, `/bots` and
`/plugins` had a zoom control that took the click and did nothing.

Collect and graft zoom alongside sizes. An absent panel id means the user has no
opinion, because `setZoom` deletes the field on reset rather than writing 1 —
that is what keeps Reset zoom reachable on a screen whose author ships a zoom of
their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 10:45:30 +01:00
pastilhasandClaude Opus 5 82d24a8feb the download card is a column, so the title stops escaping it
the thumbnail, the title and the Download button shared one row. a long title — which
on YouTube is most of them — competed with the button for the width and won, running
past the card's own border.

`truncate` was already on the title and could not help: it only bites when the box is
actually bounded, and a flex sibling that is free to be pushed out means it never is.
adding more min-w-0 would have been guessing at that; splitting the row removes the
competition.

so the card is a column now: thumbnail and text on the top row, the action on its own
below. the title has the whole width to truncate against, and the button has a row it
cannot be squeezed out of — which also means it stays put as titles change length,
rather than the layout shifting per result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:43:28 +01:00
pastilhasandClaude Opus 5 3d15f9fc8e the spinner stops sticking on cached images
my own fix from an hour ago caused this. it reset a boolean in an effect on `src`,
and that has a race which a CACHED image wins every time:

  render with the new src   ->  browser has it already, fires onLoad  ->  loaded = true
  effect runs (after paint) ->  loaded = false
  onLoad never fires again  ->  spinner forever

so the files that stuck were exactly the ones that were fastest, which is why it
read as random.

now it is DERIVED rather than reset: state holds which src has loaded, and `loaded`
is that compared to the current one. false the instant `src` changes, true the moment
that src's own load fires, and no window where the two can be written out of order.
there is nothing left to race.

zoom, rotation and pan stay in an effect. nothing else writes them during a load, and
being one render late is invisible — the hazard was only ever on the flag the browser
also writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:35:42 +01:00
pastilhasandClaude Opus 5 990078efce a new image is a new load, so the old one stops being shown
stepping through the carousel looked like the picture lagged a beat behind the
thumbnail strip. the strip was right; the picture was stale.

the renderer already had a spinner and an opacity gate — `loaded` simply was never
CLEARED when `src` changed. that did not matter while a viewer showed one file for
its whole life: the flag went true once and stayed. with a carousel it is the whole
bug, because the gate stays open and the browser goes on painting the previous
photograph until the next has decoded.

zoom, rotation and pan reset with it. they are a view of ONE image, and carrying 3x
and ninety degrees onto the next photograph puts somebody in the corner of a picture
they have not seen yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:32:12 +01:00
pastilhasandClaude Opus 5 53f3ca33b5 the file preview becomes a carousel
arrows, a thumbnail strip, and left/right/escape on the keyboard — in the viewer
itself, so BOTH hosts get it: the Files screen and any panel embedding it.

IT LISTS THE FOLDER ITSELF rather than being handed a collection. the viewer is given
one path and is reached by two screens that get there differently — a link, a search
result, the grid. listing here means neither host has to pass anything and the strip
is right however the file was opened.

THE HOST DECIDES HOW TO WRITE THE STEP, because the two genuinely disagree: the Files
screen puts a NAME in `?view=` relative to the folder in its pathname, an embedded
panel puts an absolute path. so `onNavigate` is a callback rather than the viewer
setting a param, and a host that offers none simply gets no carousel — which is the
right default for one that cannot express it.

the strip uses the FILE BROWSER'S thumbnails: same route, same cache, same 256 the
grid asks for. so a folder already browsed has a warm strip, and a HEIC works here
because it works there rather than being a second thing to teach about formats.
`getThumbUrl` joins getRawUrl and getRenderUrl as a shared builder, with the token an
<img> cannot send as a header.

directories and archives are excluded. opening an archive EXTRACTS rather than views,
so a strip you step through would offer a step that does something else entirely.

the keyboard listener ignores inputs, textareas and contenteditable — the viewer can
be editing a file, and stealing arrow keys from a text area makes the editor unusable.

it renders nothing when the folder holds fewer than two viewable files: a strip
containing the thing already on screen, beside two arrows that do nothing, is worse
than no strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:28:38 +01:00
pastilhasandClaude Opus 5 eaecf563ff Back and Forward move between folders
the folder param was written with `replace`, on my argument that browsing is not
navigation worth a history entry. that was wrong, and the URL itself gives it away:
the moment a folder is ADDRESSABLE it is a place, and Back meaning "the folder I was
just in" is what anybody expects of a place.

it also matches /files, which pushes. an embedded browser behaving differently from
the full one is a difference nobody can see a reason for — and would be discovered
by trying it, which is exactly how this was.

reading needed nothing: `currentPath` is derived from the param, so a Back that
rewrites the query moves the browser with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:15:36 +01:00
pastilhasandClaude Opus 5 321cac2e80 the folder param is relative to the base, and stops leaking an email
`?media=/officerdev/data/tatudoemcampo@gmail.com/FofosWebsite/media/originals/ABOUT+US`
became `?media=ABOUT+US`.

the length is the smaller half. the absolute form put THE ACCOUNT'S EMAIL ADDRESS in
every link — so a URL sent to a colleague, or pasted into a chat, carried the owner's
address and the shape of their disk with it. relative, it says nothing about the
machine or who owns it.

it also makes the jail hold BY CONSTRUCTION rather than by comparison. the value is
joined to `basePath`, so there is no "outside" left to check for — only `..` could
climb out, and a `..` segment is refused outright. the previous version compared a
caller-supplied absolute path against a prefix, which is the weaker shape and the one
that goes wrong when somebody later "simplifies" it.

round-trip and tampering both exercised against the real base path before committing:
base, a folder with a space, a nested folder with a non-ascii name, then `../../etc`,
`GALLERY/../../..` and `/etc/passwd` — refused, refused, contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:11:45 +01:00
pastilhasandClaude Opus 5 0ea4b822ae pathParam is actually implemented now
the previous commit added the parameter to the signature, documented it, threaded it
through FileBrowserApp — and never used it. seven lines of prose about a feature
that did not exist, committed, deployed, and reported as working.

that one fact explains all three things the owner reported: the query string never
changed because nothing wrote it; refreshing "still went to the right place" because
panelId memory was doing what it has always done; and the preview broke because the
version before it passed `urlPath`, which writes `?view=<name>` — resolvable only
when the folder is in the pathname, which in a panel it is not.

the edit failed silently because I matched on four-space indentation in a file that
uses two, and I did not check. third time today. this one asserts the replacement
changed the file and fails loudly if it did not.

what it does now, for real: current folder read from the named param, written back
with `replace`, base folder expressed as the ABSENCE of the param, and a param
pointing outside `basePath` ignored rather than obeyed — it comes from a URL somebody
can edit, and the jail is the point of an embedded browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:08:40 +01:00
pastilhasandClaude Opus 5 1da58e5231 an embedded browser can put its folder in a query param
`urlPath` was the wrong tool and I reached for it because it was there. it owns the
URL's PATHNAME — reads the folder out of `location.pathname`, navigates to
`/files/<folder>` — which is correct for the Files screen, because that screen IS
that route, and catastrophic in a panel: opening a folder in the fofos Media section
took the whole app to /files.

so an embedder gets its own mechanism rather than a shared one bent to fit.
`pathParam` names a SEARCH param and never touches the pathname. three sources of
the current folder now, mutually exclusive by construction: the pathname for /files,
a named query param for an embedder, component state for an ordinary panel.

two details that are not obvious and would each be a bug:

a param naming a folder OUTSIDE `basePath` is ignored rather than obeyed. it arrives
from a URL somebody may have edited, and the jail is the whole promise of an
embedded browser — a shared link must not be a way through it.

the base folder is the ABSENCE of the param, not a value. so a link to the top of an
embedded browser is the plain screen URL, and nothing accumulates in the query string
for having done nothing.

written with `replace`, because browsing folders is not navigation anybody wants
twenty back-button presses of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 09:01:41 +01:00
pastilhasandClaude Opus 5 cad0ea3c9c the embedded file browser can name its own root crumb
`rootLabel` on FileBrowserApp, through to the breadcrumb. it defaults to the base
folder's own name, which is right for /files and wrong for an embedder: a panel
scoped to `<root>/media/originals` is called Media, and "originals" is an
implementation detail of how that plugin lays out a directory. the person looking at
it never chose that word and has no reason to recognise it.

one prop rather than the plugin drawing its own breadcrumb — which is the same trade
as embedding the browser at all: upload, delete, thumbnails and the context menu
come from one maintained place, and this is the small thing that was missing to make
that honest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:50:30 +01:00
pastilhasandClaude Opus 5 03638a7f62 grid tiles get their gap back, and the picture fills the tile
TWO REPORTS, ONE MEASUREMENT WRONG AND ONE SHAPE WRONG.

the rows looked vertically stacked because the card OVERFLOWED its row. GRID_ROW_HEIGHT
was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text —
about 148 — so each card ate the 12px gap and the next row touched it. the horizontal
gap was fine, which is exactly what made it read as a styling oddity rather than a
number being wrong.

the height is now derived from the card instead of guessed: a 96px image band, `py-2`
around a 20px name and a 16px meta line, inside a 2px border. 152. guessing is how it
came to be 130.

THE PICTURE NOW FILLS THE TILE. it was a fixed `h-16 w-20` box, so a landscape photo
sat in the middle with air either side and a portrait one shrank to nothing. `w-full`
with the card clipping means every tile reads as the same shape whatever the
photograph is — which is the whole reason a grid of pictures is easier to scan than a
grid of names.

so the card lost its padding and gained `overflow-hidden`: the image reaches the
edges and the card does the clipping. the padding moved to the text block, the only
part that ever wanted it. the image itself is not rounded — the card clips, and
rounding both would show a sliver of card colour inside each corner.

a folder icon sits in the same 96px band, so a folder and a photograph are the same
shape and the rows line up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:29:03 +01:00
pastilhasandClaude Opus 5 000df7fe45 platform events, and thumbnails warmed the moment files arrive
a closed, named list of events — `files:uploaded`, `archive:extracted`,
`files:placed` — each carrying absolute paths and an owner. NOT an event bus in the
open sense: `emit(anyString, anything)` becomes an API nobody can change, because
the moment a plugin listens for a string that string is a contract with no list
saying so. a union means adding one is a deliberate edit and removing one fails to
compile.

TWO GUARANTEES, AND THEY ARE THE WHOLE DESIGN. emit never blocks: it schedules and
returns, so an upload does not wait for a subscriber thumbnailing a HEIC. and a
subscriber cannot fail the operation that triggered it — the upload is written and
answered before anybody is told, so a plugin that is down, slow or throwing gets a
line in the log. tested both ways, sync and async, plus that one failing listener
does not stop the next.

PLUGINS SUBSCRIBE THROUGH THE MANIFEST, same shape as `fileTypes`: `hooks: {
'files:uploaded': '/v1/on-upload' }`. resolved at EMIT time rather than at boot,
because plugins are installed, updated and disabled while the server runs and a
registration made at startup describes a machine that has since changed. nothing
here names a plugin.

CORE IS THE FIRST SUBSCRIBER, and that is deliberate rather than a shortcut. the
thumbnail cache belongs to the platform — its key, its route — so a plugin doing
this work would produce files the browser never looks at. the warmer calls the same
`buildThumbnail` the request path calls, which means HEIC still warms through
av-club via the fileTypes claim, without this file knowing that plugin exists.

AND A SINGLE-FLIGHT MAP, which the warmer made necessary. two callers now want the
same thumbnail as a matter of course: the warmer starts on upload and the browser
asks a moment later. measured before the fix — four concurrent requests plus the
warmer each decoded the same 4032x3024 HEIC and took a slot from a limit of four,
which is a warmer competing with the person waiting rather than helping them. after:
all five share one build, 5.5s once, then 130ms.

verified end to end: upload returns in 56ms, four concurrent requests all 200 in a
single 5.5s build, cached 130ms. 1679 tests, docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:20:35 +01:00
pastilhas 39c1122651 deploy report 2026.08.29.03 — seven hosts, and the tag held while master moved 2026-08-29 08:13:31 +01:00
pastilhasandClaude Opus 5 44f16fedee Shift+Enter renders as a line break
the message was never wrong, which is worth saying first: Shift+Enter puts a real
`\n` in the textarea, and a real `\n` is stored, sent, and given to the agent. what
loses it is MARKDOWN'S OWN RULE — inside a paragraph a single line ending is
whitespace, and only a blank line starts a new one. "one\ntwo" rendering as
"one two" is correct CommonMark. it is right for prose and wrong for a chat box,
where somebody pressing Shift+Enter is asking for a line.

so the fix is at RENDER time, not on the way to disk. appending two spaces to every
line as a message is saved would edit what somebody typed, and every consumer
downstream — the agent, a transcript, an export — would carry trailing whitespace
nobody asked for to satisfy a display concern.

this is what `remark-breaks` does, and it is not in the lockfile. `remark-mentions`
beside it already records why that is not a reason to add one: installs are frozen,
and a dependency for a short tree walk is a deliberate lockfile change for no
benefit. same shape, same file, one more local plugin.

IT ONLY TOUCHES TEXT NODES, and that is the property that makes it safe rather than
a precaution. by the time this runs the tree is parsed, so a newline inside a fence
is the VALUE of a `code` node and never a `text` node — fenced code, inline code and
tables cannot be reached. tested, not assumed.

empty segments are dropped, so a trailing newline gives ONE break rather than a
break followed by an empty text node — which renders as a blank line and is the
usual way this ends up double-spacing everything after it.

7 tests: the break itself, several breaks, the trailing-newline case, code and
inline code untouched, text without newlines unchanged, and nesting — emphasis,
links and mentions all wrap text, so the walk has to go in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:07:54 +01:00
35 changed files with 1448 additions and 115 deletions
+5 -4
View File
@@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**106 open, 33 closed**, across 34 themes.
**108 open, 33 closed**, across 35 themes.
| Theme | Open | Closed |
| --- | --- | --- |
@@ -40,7 +40,8 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 6 | 4 |
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 0 |
| [Known bugs](todo/known-bugs/) | 7 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
@@ -70,13 +71,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-08-29` **Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it** — [known-bugs/officer-blocks-its-event-loop-parsing-transcripts](todo/known-bugs/officer-blocks-its-event-loop-parsing-transcripts.md)
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
- `2026-08-27` **OpenSEO — a strong plugin candidate, checked rather than assumed** — [integrations/openseo-a-plugin-candidate](todo/integrations/openseo-a-plugin-candidate.md)
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
+51
View File
@@ -0,0 +1,51 @@
# 2026.08.29.03 — deploy report
**7 of 7 target hosts succeeded. `officer-dev` skipped, as required.**
Tag `deploy/2026.08.29.03` → `03fbb118`, matching `package.json`. Plan:
[`deploy/2026.08.29.03.md`](2026.08.29.03.md). Every host ended at `03fbb118`.
Rolled out one host at a time, first release under a new watcher.
## Per host
| Host | HEAD before | HEAD after | `plugins:update` | `officer` restart_time | health | `file-browser/ls` |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `78bf63c2` | `03fbb118` | 15/15 | 4 → 5 | 401 | 200, 76 entries |
| `fofos` | `21665ed0` | `03fbb118` | 5/5 | 45 → 46 | 401 | 200, 38 entries |
| `macbookpro` | `78bf63c2` | `03fbb118` | none installed | 4 → 5 | 401 (:9010) | 200, 62 entries |
| `macbruno` | `78bf63c2` | `03fbb118` | 1/1 | 7 → 8 | 401 | 200, 41 entries |
| `production-pertento-bridge` | `78bf63c2` | `03fbb118` | 1/1 | 5 → 6 | 401 (:9010) | 200, 53 entries |
| `edge-pertento-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 6 → 7 | 401 | 200, 29 entries |
| `deadpool-rezio-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 23 → 24 | 401 | 200, 49 entries |
Every host: tracked tree clean, `git merge --ff-only deploy/2026.08.29.03` fast-forwarded, `officer`
came back `online` with a moved `restart_time`, and no process on any host was left off-line. Ports
were read from each checkout's own `.env` rather than assumed. `401` on `/api/health` is healthy per
the plan.
## What was NOT done
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. The rollout ran there, inside
`officer-claude-code`; restarting would have ended it mid-deploy. It is already at `03fbb118`, so no
code is missing — only its `officer` process still runs the previous build. The owner restarts it
deliberately.
- **No `bun db:push`, no `bun install`, no SQL.** The plan states there is no schema and no dependency
change, and says improvising either is itself the error. Nothing was queried because nothing was
changed. **This release has no schema verification, by design.**
- **Orphaned `todos` rows in `role_permissions` left alone**, as the plan instructs. Absence denies.
## Worth knowing
- **A commit landed on `master` mid-rollout.** `origin/master` reached `44f16fed` while hosts 4–7 were
fetching. No host took it: every merge targeted the tag, not the branch, so the fleet is uniformly at
`03fbb118`. `44f16fed` is undeployed and rides the next release. This is the 2026-08-23 failure not
happening.
- **`plugins:update` moved more than `officer`.** It restarts sidecars itself — around twelve on
`alpha` — and added and started one each on `production-pertento-bridge` (gitea),
`edge-pertento-bridge` (offscale) and `deadpool-rezio-bridge` (email).
- **`fofos` has an untracked `scripts/chk.ts`.** Left in place. Untracked files are a note, not a skip.
- **`edge-pertento-bridge` needed no special handling.** `docs/servers.md` warns it may be far behind
after its outage; it was already at `deploy/2026.08.29.02`. That warning is now stale.
- **`/todos` now 404s on all seven hosts.** Intended — the board is the `kanban` plugin, installed
nowhere by default.
+90
View File
@@ -0,0 +1,90 @@
# 2026.08.29.04 — the file preview becomes a carousel, and zoom starts working again
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 | grep -iE 'schema|/db/|databases' # nothing
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
Sixteen commits; two of them are notes and one is the previous release's report.
## What is in it
- **The file preview is a carousel.** It lists the folder it was opened from, shows thumbnails along the
bottom, and steps with the arrow keys or the carets. Escape closes it.
- **Zoom works on plugin screens, `/bots` and `/plugins` again.** `useAuthoredLayout` grafts back only
what the user owns, and it knew about sizes and not about zoom — so every click was collected by
nothing, compared equal, and dropped. The control rendered, took the click, and did nothing.
- **An image no longer sticks.** Stepping to a new picture showed the previous one until the new one
decoded, and the first fix for that lost a race against cached images and left the spinner up forever.
Both are gone: loaded state is derived from which `src` actually loaded.
- **A platform event bus**, and thumbnails warmed the moment files arrive. `files:uploaded`,
`files:placed` and `archive:extracted` are emitted; a plugin may subscribe through its manifest. No
plugin subscribes yet, so the dispatch path is unexercised in production — worth knowing if something
odd appears near an upload.
- **An embedded file browser can put its folder in a query parameter**, keep its own root crumb, and
Back and Forward move between folders.
- Shift+Enter renders as a line break in chat. Grid tiles have their gap back and the picture fills the
tile. The download card's title stops escaping it.
## Nothing here changes behaviour anyone has to be warned about
No route is removed, no permission changes, nothing 404s that did not before. If a host comes back and
something is missing, that is a fault rather than an intention — report it.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.04`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
### Verify it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
### And check the file browser still lists
This release touched the file browser and the viewer heavily, so one call per host:
```bash
bun -e "
const { sign } = await import('./src/servers/jwt');
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
process.exit(0)"
```
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
## Per host
Surveyed 2026-08-29 while writing this. Every host was clean (`--untracked-files=no`) and six of the
seven sit exactly on `deploy/2026.08.29.03` (`03fbb118`).
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
| `alpha` | On `03fbb118`, clean. Ordinary target. |
| `fofos` | **On `2738f799`, not on the tag** — it has been hand-pulled all day while this work was written against it. That commit is an ancestor of the tag, so the fast-forward is ordinary; it simply has fewer commits to travel. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note, not a failure. |
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
| `edge-pertento-bridge` | Answered the survey this time, on `03fbb118` and clean. Ordinary target. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`, and the `ls` check's output.
Nothing in this release needs a database query, a backup or an install. If you find yourself running
one, stop — the plan is wrong, and that is worth reporting rather than working around.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.29.03",
"version": "2026.08.29.04",
"private": true,
"type": "module",
"workspaces": [
+15
View File
@@ -5,6 +5,8 @@ import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertPermissionTotality } from './servers/permissions/totality';
import { bootstrapCaldav } from './servers/caldav-bootstrap';
import { registerThumbnailWarmer } from './servers/events/warm-thumbnails';
import { registerPluginHooks } from './servers/events/plugin-hooks';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { listPluginRootClaims } from './servers/plugins/public-paths';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
@@ -252,6 +254,19 @@ if (secured.repaired.length) {
}
for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`);
/*
Subscribe to platform events before anything can emit one.
Both are pure registration — no I/O, nothing that can fail — so they sit above the plugin mount rather
than inside its try. If either threw, the server not starting would be the correct outcome, and
wrapping them in the plugin block's `catch` would hide that.
The thumbnail warmer is core subscribing to core: the cache belongs to the platform, so the work has to
happen here. Plugin hooks are the general mechanism, resolved per event from whatever is installed.
*/
registerThumbnailWarmer();
registerPluginHooks();
// Mount whatever is installed, BEFORE serve().
//
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,
+32
View File
@@ -11,6 +11,7 @@ import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb';
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
import { emit } from '../../events/bus';
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
@@ -333,6 +334,7 @@ router.post('/upload', async (ctx) => {
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
create is a worse trade than a fragment somebody can see.
*/
const written: string[] = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const filePath = resolve(targetDir, file.name);
@@ -340,8 +342,19 @@ router.post('/upload', async (ctx) => {
await mkdir(dirname(filePath), { recursive: true });
await writeAtomic(filePath, file);
written.push(filePath);
}
/*
Announced AFTER the writes and BEFORE the response, which is the only ordering that is correct.
After, because a subscriber reads these from disk and they have to be there. Before returning is
fine because `emit` schedules and returns — the upload does not wait for anybody. What it must never
become is an await: a subscriber thumbnailing a HEIC would add five seconds to an upload that has
already finished.
*/
emit('files:uploaded', { paths: written, email: user.email });
return ctx.json({ ok: true });
});
@@ -1235,6 +1248,25 @@ router.post('/extract', async (ctx) => {
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
}
/*
What actually came out, walked once rather than guessed from the archive listing.
Bounded, because an archive can hold a hundred thousand files and this runs on the request's way
out. The warmer has its own cap too; this one keeps the WALK from being the expensive part.
*/
const unpacked: string[] = [];
const collect = async (dir: string, depth = 0): Promise<void> => {
if (depth > 8 || unpacked.length >= 500) return;
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
if (unpacked.length >= 500) return;
const full = join(dir, entry.name);
if (entry.isDirectory()) await collect(full, depth + 1);
else unpacked.push(full);
}
};
await collect(finalDest);
emit('archive:extracted', { paths: unpacked, email: user.email });
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ extractedPath });
});
+53 -27
View File
@@ -138,6 +138,19 @@ const release = () => {
* file another chance after somebody has installed a codec or replaced the file. Bounded, because a
* long-lived process browsing a lot of broken media should not grow without limit.
*/
/**
* Builds in flight, by cache key — so the same thumbnail is never made twice at once.
*
* Two callers want the same file all the time now: the warmer starts on upload, and the browser asks
* for it a moment later. Without this they both decode the same 4032x3024 HEIC, take a slot each from a
* limit of four, and finish at the same time having done the work twice — which turns a warmer meant to
* help into one competing with the person waiting.
*
* Keyed on the same string the cache is, so it deduplicates exactly what would collide: same file, same
* size, same content. Removed in a `finally`, or one failure would wedge that key until a restart.
*/
const inFlight = new Map<string, Promise<ThumbnailResult>>();
const failed = new Set<string>();
const FAILED_LIMIT = 5_000;
@@ -245,34 +258,39 @@ export async function buildThumbnail(
const cached = await stat(cachePath).catch(() => null);
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
await mkdir(cacheDir, { recursive: true });
await acquire();
try {
/*
// Somebody else is already making this one. Wait for theirs rather than starting a second.
const running = inFlight.get(key);
if (running) return running;
const work = (async (): Promise<ThumbnailResult> => {
await mkdir(cacheDir, { recursive: true });
await acquire();
try {
/*
`force_original_aspect_ratio=decrease` fits inside the box without cropping, and never enlarges
past the source — a 40px icon blown up to 256 is worse than a small one. `-2` on the odd dimension
keeps it even, which the JPEG encoder requires for chroma subsampling.
*/
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
let ok: boolean;
if (produce) {
const bytes = await produce().catch(() => null);
if (bytes?.length) await Bun.write(cachePath, bytes);
ok = !!bytes?.length;
} else if (kind === 'image') {
ok = await resizeImage(absPath, cachePath, size);
} else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
// broken file. Take the first frame instead before giving up on it.
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
}
let ok: boolean;
if (produce) {
const bytes = await produce().catch(() => null);
if (bytes?.length) await Bun.write(cachePath, bytes);
ok = !!bytes?.length;
} else if (kind === 'image') {
ok = await resizeImage(absPath, cachePath, size);
} else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
// broken file. Take the first frame instead before giving up on it.
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
}
if (!ok) {
/*
if (!ok) {
/*
A PRODUCER'S FAILURE IS NOT REMEMBERED, and that distinction is the whole point of this branch.
When sharp or ffmpeg fails, the file is the problem — it will fail identically forever, and
@@ -284,12 +302,20 @@ export async function buildThumbnail(
the SERVER was restarted — no client retry could ever get past it, because the answer was being
served from memory without the plugin being asked again.
*/
if (!produce) rememberFailure(key);
return { ok: false, reason: 'could not read an image from this file' };
}
if (!produce) rememberFailure(key);
return { ok: false, reason: 'could not read an image from this file' };
}
return { ok: true, path: cachePath };
return { ok: true, path: cachePath };
} finally {
release();
}
})();
inFlight.set(key, work);
try {
return await work;
} finally {
release();
inFlight.delete(key);
}
}
+71
View File
@@ -0,0 +1,71 @@
import { expect, test } from 'bun:test';
import { emit, listenerCount, on } from './bus';
const settle = () => new Promise((r) => setTimeout(r, 10));
test('a listener is told, with the payload', async () => {
const seen: string[][] = [];
const off = on('files:uploaded', (p) => void seen.push(p.paths));
emit('files:uploaded', { paths: ['/a', '/b'], email: 'x@y' });
await settle();
expect(seen).toEqual([['/a', '/b']]);
off();
});
test('unsubscribing stops it', async () => {
let calls = 0;
const off = on('files:uploaded', () => void calls++);
off();
emit('files:uploaded', { paths: ['/a'], email: 'x@y' });
await settle();
expect(calls).toBe(0);
});
// The property the whole design rests on: a subscriber must never be able to fail the operation that
// triggered it. An upload is already written and answered by the time this runs.
test('emit does not throw when a listener throws synchronously', async () => {
const off = on('files:uploaded', () => {
throw new Error('boom');
});
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
await settle();
off();
});
test('emit does not throw when a listener rejects', async () => {
const off = on('files:uploaded', async () => {
throw new Error('boom');
});
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
await settle();
off();
});
// One bad subscriber must not stop the others — they are independent, not a chain.
test('a failing listener does not stop the next one', async () => {
let reached = false;
const offBad = on('archive:extracted', () => {
throw new Error('boom');
});
const offGood = on('archive:extracted', () => void (reached = true));
emit('archive:extracted', { paths: [], email: 'x@y' });
await settle();
expect(reached).toBe(true);
offBad();
offGood();
});
test('emit returns immediately rather than awaiting listeners', () => {
const off = on('files:placed', async () => {
await new Promise((r) => setTimeout(r, 200));
});
const started = performance.now();
emit('files:placed', { paths: [], email: 'x@y' });
expect(performance.now() - started).toBeLessThan(50);
off();
});
test('an event with no listeners is a no-op', () => {
expect(listenerCount('files:placed')).toBe(0);
expect(() => emit('files:placed', { paths: [], email: 'x@y' })).not.toThrow();
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Platform events, and who is told about them.
*
* ── A NAMED LIST, not an event bus ──
*
* The type below is closed on purpose. An open bus — `emit(anyString, anyPayload)` — becomes an API
* nobody can change: the moment a plugin listens for `file:thing`, that string and the shape beside it
* are a contract, and there is no list anywhere saying so. A closed union means adding an event is a
* deliberate edit here, and removing one fails to compile rather than failing quietly at runtime.
*
* ── Every payload is absolute paths and an owner ──
*
* Not a file's contents, not a request, not a user object. A subscriber that needs more should read it
* from the path, because by the time this fires the work is done and the disk is the truth. Passing
* more would be inventing a second description of something already on the filesystem.
*
* ── Emitting NEVER blocks and NEVER throws ──
*
* This is fired after a response is decided, and the response must not wait for it. An upload that
* takes five extra seconds because a subscriber is thumbnailing a HEIC is an upload somebody reports as
* broken; a failing subscriber must not turn a completed upload into an error the caller sees.
*
* So `emit` returns immediately and swallows everything. That is deliberate rather than lazy: a
* subscriber's failure is ITS failure, logged with its name, and the operation that triggered it has
* already succeeded.
*/
/** Absolute paths that now exist, and whose account they belong to. */
export type FileEventPayload = {
/** Absolute. Files, not directories — a subscriber wanting the folder can take the dirname. */
paths: string[];
/** The account the operation was performed for. */
email: string;
};
export type PlatformEvents = {
/** One or more files arrived through `POST /file-browser/upload`. */
'files:uploaded': FileEventPayload;
/** An archive was unpacked. `paths` is what came out of it. */
'archive:extracted': FileEventPayload;
/** Files were copied or moved to a new location. `paths` is where they are NOW. */
'files:placed': FileEventPayload;
};
export type EventName = keyof PlatformEvents;
type Listener<K extends EventName> = (payload: PlatformEvents[K]) => void | Promise<void>;
const listeners = new Map<EventName, Set<Listener<EventName>>>();
/**
* Subscribe. Returns the unsubscribe, which nothing currently calls — core's own subscribers live for
* the process — but returning it means a future one can be torn down without changing this.
*/
export function on<K extends EventName>(event: K, listener: Listener<K>): () => void {
const set = listeners.get(event) ?? new Set();
set.add(listener as Listener<EventName>);
listeners.set(event, set);
return () => set.delete(listener as Listener<EventName>);
}
/**
* Tell everyone, and get out of the way.
*
* Synchronous return, work scheduled after. `queueMicrotask` rather than awaiting: the caller is a
* request handler about to write a response, and the point is that it does not wait.
*/
export function emit<K extends EventName>(event: K, payload: PlatformEvents[K]): void {
const set = listeners.get(event);
if (!set?.size) return;
for (const listener of set) {
queueMicrotask(() => {
try {
// A listener returning a rejected promise is the common case — most of them are async — and it
// needs catching separately from a synchronous throw.
void Promise.resolve(listener(payload)).catch((err: unknown) => {
console.error(`[events] ${event} listener failed —`, err instanceof Error ? err.message : err);
});
} catch (err) {
console.error(`[events] ${event} listener threw —`, err instanceof Error ? err.message : err);
}
});
}
}
/** For tests: how many listeners an event has. Nothing in the server should need this. */
export const listenerCount = (event: EventName): number => listeners.get(event)?.size ?? 0;
+76
View File
@@ -0,0 +1,76 @@
import { snapshotPlugins } from '../plugins/mount';
import { sidecarPort } from '../sidecar-registry';
import { on, type EventName, type FileEventPayload } from './bus';
/**
* Letting plugins subscribe to platform events.
*
* ── The same shape as `fileTypes`, deliberately ──
*
* A manifest declares `hooks: { 'files:uploaded': '/v1/on-upload' }`. Core resolves that at emit time
* from whatever is installed and enabled, POSTs the payload to the plugin's own sidecar, and knows
* nothing about which plugin it is. Disable the plugin and the hook stops firing, with nothing to
* unregister — the same property that makes a claimed file type disappear cleanly.
*
* ── Resolved at EMIT time, not at boot ──
*
* The obvious design registers every plugin's hooks once at startup. It is wrong here: plugins are
* installed, updated, enabled and disabled while the server runs, and a registration made at boot
* describes a machine that has since changed. Reading the current set costs one database call on an
* event that has just done filesystem work, which is not the expensive part of anything.
*
* ── A hook cannot fail the operation that triggered it ──
*
* `emit` already swallows, and this adds nothing that could throw past it. The upload succeeded before
* anybody was told. A plugin that is down, slow, or returns 500 gets a line in the log and no more —
* because the alternative is an upload that reports failure for work that has nothing to do with it.
*/
/** Long enough for real work, short enough that a wedged plugin does not hold a socket all day. */
const HOOK_TIMEOUT_MS = 120_000;
async function dispatch(event: EventName, payload: FileEventPayload): Promise<void> {
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
try {
({ states } = await snapshotPlugins());
} catch {
return; // a database that cannot be read is a louder problem elsewhere
}
for (const state of states) {
if (!state.install?.enabled) continue;
const path = state.plugin.manifest?.hooks?.[event];
if (!path) continue;
const port = sidecarPort(state.plugin.appName);
// Declared but not running — restarting, or never started. Skipped silently: a plugin being down is
// not an error of the upload that happened to coincide with it.
if (!port) continue;
try {
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-officer-user': payload.email },
body: JSON.stringify({ event, ...payload }),
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS),
});
if (!res.ok) {
console.warn(`[hooks] ${state.plugin.appName} ${event} -> ${res.status}`);
}
} catch (err) {
console.warn(`[hooks] ${state.plugin.appName} ${event} failed —`, err instanceof Error ? err.message : err);
}
}
}
/**
* Wire plugin dispatch into every event. Called once, from the server's own start-up.
*
* One listener per event rather than one per plugin: which plugins care is resolved when the event
* fires, so there is nothing to add or remove as they come and go.
*/
export function registerPluginHooks(): void {
const events: EventName[] = ['files:uploaded', 'archive:extracted', 'files:placed'];
for (const event of events) on(event, (payload) => dispatch(event, payload));
}
+107
View File
@@ -0,0 +1,107 @@
import { join } from 'node:path';
import { DATA_PATH } from '../data-path';
import { buildThumbnail, thumbnailKind } from '../api/file-browser/thumbnails';
import { pluginFileTypes, sidecarFetch } from '../api/file-browser/plugin-file-types';
import { on, type FileEventPayload } from './bus';
/**
* Make the thumbnails before anybody asks for them.
*
* ── Why this is worth doing at all ──
*
* Thumbnails are generated on demand and cached. That is right — most files are never looked at — but
* it means the first person to open a folder waits for the work. For a jpeg that is 40ms nobody
* notices. For a HEIC it is four and a half seconds each, because ImageMagick decodes the whole
* 4032x3024 frame before sharp sees it. A folder of photographs from a phone is thirteen seconds of
* staring at file icons.
*
* An upload is the one moment the platform KNOWS which files are new, so it is the moment to do it.
*
* ── This is core subscribing to core, and that is the point ──
*
* The thumbnail cache belongs to the platform: keyed on its scheme, read by its route. A plugin doing
* this work would produce them somewhere the file browser never looks. So the warmer lives here and
* uses the same `buildThumbnail` the request path uses — which means a HEIC still warms through
* av-club, via the `fileTypes` claim, without this file knowing that plugin exists.
*
* ── ONE size, and it is the one both views ask for ──
*
* The cache key includes the size. Warming 128 when the browser asks for 256 is work that produces
* nothing anybody reads, so this must stay equal to what `FileItem` requests. Both views were made to
* ask for 256 for exactly this reason.
*/
const WARM_SIZE = 256;
/**
* How many to warm at once.
*
* Lower than the request path's own limit on purpose. This is background work competing with a person
* who is actually waiting: somebody opening a different folder while an upload warms must not queue
* behind it. `buildThumbnail` has its own cap too, so this is a second, tighter gate rather than the
* only one.
*/
const WARM_CONCURRENCY = 2;
/** Stop after this many. An unpacked archive can hold thousands, and warming all of them is not a favour. */
const WARM_LIMIT = 200;
async function warm(payload: FileEventPayload): Promise<void> {
const claimed = await pluginFileTypes();
// Only what will actually be asked for: core's own types plus whatever a plugin claims. Anything else
// has no thumbnail to make and would be a spawn per file for nothing.
const targets = payload.paths
.filter((path) => {
const name = path.slice(path.lastIndexOf('/') + 1);
return !!thumbnailKind(name) || claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
})
.slice(0, WARM_LIMIT);
if (targets.length === 0) return;
const userDataDir = join(DATA_PATH, payload.email);
let index = 0;
const worker = async () => {
while (index < targets.length) {
const path = targets[index++]!;
const name = path.slice(path.lastIndexOf('/') + 1);
const kind = thumbnailKind(name);
const claim = kind ? null : claimed.get(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
/*
Exactly what the request path does, including its cache and its remembered failures — so a file
warmed here is a cache HIT when the browser asks, and a file that cannot be thumbnailed is not
attempted twice.
*/
await buildThumbnail(
path,
userDataDir,
kind ?? 'image',
WARM_SIZE,
claim?.thumbnail
? async () => {
const res = await sidecarFetch(
`${claim.thumbnail}?path=${encodeURIComponent(path)}&size=${WARM_SIZE}`,
payload.email,
);
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
}
: undefined,
).catch(() => {
// Already logged by the builder where it matters. A warm that fails costs nothing: the request
// path will try again and answer honestly.
});
}
};
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, targets.length) }, worker));
}
/** Called once from start-up. */
export function registerThumbnailWarmer(): void {
on('files:uploaded', warm);
on('archive:extracted', warm);
on('files:placed', warm);
}
+14
View File
@@ -509,6 +509,20 @@ export type PluginManifest = {
render?: string;
};
/**
* Platform events this plugin wants to be told about, and where to tell it.
*
* `{ 'files:uploaded': '/v1/on-upload' }` — the key is an event from `servers/events/bus.ts`, the
* value a path on this plugin's own sidecar. Core POSTs `{ event, paths, email }` to it after the
* operation has already succeeded.
*
* **A hook cannot fail the thing that triggered it.** The upload is done and answered before anybody
* is told; a plugin that is down, slow or erroring gets a line in the log. Nothing here is a way to
* veto or modify an operation, and that is deliberate — a hook that could refuse an upload would make
* every upload's reliability the worst of every installed plugin's.
*/
hooks?: Partial<Record<string, string>>;
osDependencies?: OsDependency[];
/**
@@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { remarkMentions, MENTION_HREF } from './remark-mentions';
import remarkGfm from 'remark-gfm';
import { remarkLineBreaks } from './remark-line-breaks';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import {
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
base64 blob pushes the bubble past max-w and out of the pane. */}
<div className="chat-md break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMentions]}
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -248,7 +249,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
<div className="chat-md">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMentions]}
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -546,7 +547,7 @@ const PlanCard = ({
</div>
<div className="max-h-96 overflow-y-auto break-words">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions]} components={MD_COMPONENTS}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]} components={MD_COMPONENTS}>
{message.plan}
</ReactMarkdown>
</div>
@@ -637,7 +638,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
{text ? (
<div className="chat-md">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMentions]}
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -0,0 +1,61 @@
import { expect, test } from 'bun:test';
import { remarkLineBreaks } from './remark-line-breaks';
type MdNode = { type: string; value?: string; children?: MdNode[] };
const run = (tree: MdNode): MdNode => {
remarkLineBreaks()(tree);
return tree;
};
const para = (...children: MdNode[]): MdNode => ({ type: 'paragraph', children });
const text = (value: string): MdNode => ({ type: 'text', value });
test('a single newline becomes a break', () => {
const tree = run({ type: 'root', children: [para(text('one\ntwo'))] });
expect(tree.children![0]!.children).toEqual([
{ type: 'text', value: 'one' },
{ type: 'break' },
{ type: 'text', value: 'two' },
]);
});
test('several newlines make several breaks', () => {
const tree = run({ type: 'root', children: [para(text('a\nb\nc'))] });
expect(tree.children![0]!.children!.filter((n) => n.type === 'break')).toHaveLength(2);
});
// The empty-segment case: a trailing newline must give one break, never a break plus an empty text
// node, which renders as an extra blank line and double-spaces everything after it.
test('a trailing newline does not leave an empty text node', () => {
const tree = run({ type: 'root', children: [para(text('one\n'))] });
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'one' }, { type: 'break' }]);
});
// The property that makes this safe: by the time it runs, a newline inside a fence is the VALUE of a
// `code` node, never a `text` node. Walking text alone cannot reach it.
test('code blocks are untouched', () => {
const code: MdNode = { type: 'code', value: 'line one\nline two' };
const tree = run({ type: 'root', children: [code] });
expect(tree.children![0]).toEqual({ type: 'code', value: 'line one\nline two' });
});
test('inline code is untouched', () => {
const tree = run({ type: 'root', children: [para({ type: 'inlineCode', value: 'a\nb' })] });
expect(tree.children![0]!.children![0]).toEqual({ type: 'inlineCode', value: 'a\nb' });
});
test('text without newlines is left exactly as it was', () => {
const tree = run({ type: 'root', children: [para(text('nothing to do here'))] });
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'nothing to do here' }]);
});
// Emphasis, links and mentions all nest text — the walk has to go into them, not just the top level.
test('nested nodes are walked', () => {
const tree = run({ type: 'root', children: [para({ type: 'emphasis', children: [text('one\ntwo')] })] });
expect(tree.children![0]!.children![0]!.children).toEqual([
{ type: 'text', value: 'one' },
{ type: 'break' },
{ type: 'text', value: 'two' },
]);
});
@@ -0,0 +1,67 @@
/**
* Make a single newline a line break, the way every chat app does.
*
* ── The message was never wrong ──
*
* Shift+Enter puts a real `\n` in the textarea and a real `\n` is stored, sent and given to the agent.
* What loses it is MARKDOWN'S OWN RULE: inside a paragraph, a single line ending is whitespace, and only
* a blank line starts a new paragraph. So
*
* one
* two
*
* renders as "one two", correctly, per CommonMark. That is right for prose and wrong for a chat box,
* where somebody pressing Shift+Enter is asking for a line.
*
* The fix therefore belongs at RENDER time. The alternative — appending two spaces to every line as the
* message is saved — would edit what somebody typed on its way to disk, and every consumer downstream
* (the agent, a transcript, an export) would carry trailing whitespace nobody asked for to satisfy a
* display concern.
*
* This is what `remark-breaks` does. It is not in the lockfile, and `remark-mentions` beside this file
* records the reasoning for not adding one: installs are frozen, and a dependency for a short tree walk
* is a deliberate lockfile change for no benefit.
*
* ── Why the split is only inside text nodes ──
*
* By the time this runs the tree is already parsed, so a newline inside a code fence is the VALUE of a
* `code` node and never a `text` node — walking text alone means fenced code, inline code and tables are
* untouched. That is the property that makes this safe: it cannot reach anything where a newline means
* something else.
*/
type MdNode = { type: string; value?: string; children?: MdNode[] };
export function remarkLineBreaks() {
return (tree: MdNode) => {
const walk = (node: MdNode) => {
if (!node.children?.length) return;
const next: MdNode[] = [];
for (const child of node.children) {
if (child.type !== 'text' || !child.value?.includes('\n')) {
walk(child);
next.push(child);
continue;
}
/*
`one\ntwo` becomes text("one"), break, text("two").
Empty segments are dropped rather than emitted, so a trailing newline produces one break and
not a break followed by an empty text node — which renders as an extra blank line and is the
usual way this ends up double-spacing everything.
*/
const parts = child.value.split('\n');
parts.forEach((part, index) => {
if (index > 0) next.push({ type: 'break' });
if (part) next.push({ type: 'text', value: part });
});
}
node.children = next;
};
walk(tree);
};
}
@@ -11,6 +11,15 @@ import { useFileBrowserApp } from './useFileBrowserApp';
type FileBrowserAppProps = {
basePath?: string;
rootOverride?: string;
/** What the root crumb is called. Defaults to the base folder's own name. */
rootLabel?: string;
/**
* Keep the current folder in this query param, on the route you are already on.
*
* For an embedded browser that wants a reloadable, shareable location. NOT `urlPath`, which owns the
* pathname and navigates to `/files` — passing that from a panel takes the whole app with it.
*/
pathParam?: string;
/** Put the current folder in `?path=`. Opt-in — only the /files screen is guaranteed one browser. */
urlPath?: boolean;
/**
@@ -20,8 +29,15 @@ type FileBrowserAppProps = {
panelId?: string;
};
export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId);
export const FileBrowserApp = ({
basePath = '/',
rootOverride,
rootLabel,
urlPath,
panelId,
pathParam,
}: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam);
const { handleNavigate } = fileBrowserManager;
return (
@@ -31,6 +47,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId
path={fileBrowserManager.currentPath}
onNavigate={handleNavigate}
basePath={basePath}
rootLabel={rootLabel}
hrefForPath={fileBrowserManager.hrefForPath}
/>
<UploadProgress fileBrowserManager={fileBrowserManager} />
@@ -5,6 +5,14 @@ type BreadcrumbProps = {
path: string;
onNavigate: (path: string) => void;
basePath?: string;
/**
* What to call the root crumb.
*
* Defaults to the base folder's own name, which is right for `/files` and wrong for an embedder: a
* panel scoped to `<root>/media/originals` is called Media, and "originals" is an implementation
* detail of how that plugin lays out a directory. The person looking at it never chose that word.
*/
rootLabel?: string;
/**
* Where a folder lives, when this browser owns the address bar. Given, the crumbs are real links and
* cmd-click opens the folder in a new tab; absent (a dashboard panel, the widget) they stay buttons,
@@ -15,7 +23,7 @@ type BreadcrumbProps = {
hrefForPath?: ((path: string) => { pathname: string; search: string }) | null;
};
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: BreadcrumbProps) => {
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath, rootLabel }: BreadcrumbProps) => {
const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
const segments = relativePath.split('/').filter(Boolean);
@@ -35,7 +43,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
<nav className="flex items-center gap-1 text-sm flex-wrap">
<Crumb to={basePath} className="flex items-center gap-1 text-duck-teal cursor-pointer font-medium">
<Home className="h-4 w-4" />
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
<span>{rootLabel ?? (basePath === '/' ? 'home' : basePath.split('/').pop())}</span>
</Crumb>
{segments.map((segment, i) => {
@@ -15,7 +15,19 @@ type FileGridProps = {
};
const LIST_ROW_HEIGHT = 42;
const GRID_ROW_HEIGHT = 130;
/*
What one grid row actually occupies, and why the number changed.
It was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text — about 148. The
card overflowed its own row by roughly the size of the gap, so consecutive rows touched and the grid
looked vertically stacked. The horizontal gap was fine, which is what made it read as a styling
oddity rather than a measurement being wrong.
Now: a 96px image band, then `py-2` (16) around a 20px name and a 16px meta line, inside a 2px
border. 96 + 16 + 20 + 16 + 4 = 152. Measured against the card rather than guessed, because guessing
is how it came to be 130.
*/
const GRID_ROW_HEIGHT = 152;
const GRID_GAP = 12;
const HEADER_HEIGHT = 36;
// Type-ahead: keys pressed within this window accumulate into one burst. 1–2 chars jump to the first
@@ -641,7 +641,20 @@ export const FileItem = ({
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
*/
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
/*
The grid picture fills the card's WIDTH and is cropped by height.
It used to be a fixed `h-16 w-20` box centred with `object-contain`, so a landscape photo sat in the
middle of the card with air either side and a portrait one shrank to nothing. `w-full` with the
card's own `overflow-hidden` doing the clipping means every tile reads as the same shape whatever
the photograph is — which is the whole reason a grid of pictures is easier to scan than a grid of
names.
No rounding here: the card clips, so rounding the image too would show a sliver of card colour
inside the corner.
*/
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-24 w-full', 256) : iconLarge;
useEffect(() => {
return () => {
@@ -784,7 +797,7 @@ export const FileItem = ({
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors ${
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
} ${cutOpacity} select-none`}
style={selected ? undefined : cardStyle()}
@@ -800,23 +813,27 @@ export const FileItem = ({
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div>
{gridVisual}
{/* One fixed-height band whether it holds a photograph or a folder icon, so every tile is
the same shape and the rows line up. The card clips; this does not round. */}
<div className="flex h-24 w-full shrink-0 items-center justify-center overflow-hidden">{gridVisual}</div>
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
)}
<div className="flex min-w-0 flex-col items-center gap-0.5 px-2 py-2">
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
)}
<span className="text-xs text-duck-dark/50">
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
{' · '}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="text-xs text-duck-dark/50">
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
{' · '}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
@@ -252,48 +252,57 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
{entries.map((e, i) => (
<div
key={`${e.url}-${i}`}
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
/*
A COLUMN, not a row. The title, the thumbnail and the button used to share one
line, so a long title — which is most of them — competed with the button for width
and won: `truncate` cannot bite while a sibling can be pushed out of the card.
Split, the title has the whole width to truncate against and the button has a row
it cannot be squeezed out of.
*/
className="flex flex-col gap-2 rounded-md border border-duck-dark/10 bg-background/40 p-2"
>
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
{e.status === 'loading' ? (
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
) : e.status === 'error' ? (
<AlertCircle size={16} className="text-destructive/70" />
) : e.thumbnail && !audioOnly ? (
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
) : (
<Music size={16} className="text-duck-dark/40" />
)}
</div>
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
{e.status === 'loading' ? (
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
) : e.status === 'error' ? (
<AlertCircle size={16} className="text-destructive/70" />
) : e.thumbnail && !audioOnly ? (
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
) : (
<Music size={16} className="text-duck-dark/40" />
)}
</div>
<div className="min-w-0 flex-1">
{e.status === 'loading' ? (
<div className="space-y-1.5">
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-destructive">
{t('videoDownload.couldNotFetch')}
</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
<>
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
{e.title || e.url}
</p>
<p className="truncate text-xs text-duck-dark/50">
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
</p>
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
</>
)}
<div className="min-w-0 flex-1">
{e.status === 'loading' ? (
<div className="space-y-1.5">
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-destructive">
{t('videoDownload.couldNotFetch')}
</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
<>
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
{e.title || e.url}
</p>
<p className="truncate text-xs text-duck-dark/50">
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
</p>
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
</>
)}
</div>
</div>
{e.status === 'ready' && (
<div className="shrink-0">
<div className="flex justify-end">
{e.dl === 'done' ? (
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
<Check size={14} /> {t('videoDownload.saved')}
@@ -24,7 +24,25 @@ import { useTranslation } from '../../../locales';
*/
export const FILES_PATH_PARAM = 'path';
export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false, panelId?: string) => {
/**
* `pathParam` — keep the current folder in a QUERY PARAM, on whatever route we are already on.
*
* This is not `urlPath` and must not be confused with it. `urlPath` owns the URL's PATHNAME: it reads
* the folder out of `location.pathname` and navigates to `/files/<folder>`. That is correct for the
* Files screen, which IS that route, and catastrophic inside a panel — a plugin passing `urlPath`
* navigated the whole app to /files the moment anybody opened a folder.
*
* So an embedder gets its own mechanism: a named search param, written with `replace` so browsing does
* not fill the back stack, and no pathname involved at all. A reload lands where you were and the link
* can be sent to somebody, which is what was actually wanted.
*/
export const useFileBrowserApp = (
basePath: string,
rootOverride?: string,
urlPath = false,
panelId?: string,
pathParam?: string,
) => {
const { t } = useTranslation();
const { user } = useAuth();
const navigate = useNavigate();
@@ -64,14 +82,55 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
// visit still opens at home, because a URL with no `path` means the base. Either way the main
// (unscoped) browser mirrors its folder into files/currentPath so the Create Dashboard flow can
// default a new dashboard's cwd to it.
const currentPath = urlPath ? folderFromPathname(location.pathname) : localPath;
/*
Three sources, mutually exclusive by construction: the pathname for /files, a named query param for
an embedded browser, and component state for an ordinary panel.
── The param is RELATIVE to the base, and that is not only for tidiness ──
An absolute path put the account's own email in every link — `?media=/officerdev/data/
someone@gmail.com/...` — so a URL sent to a colleague carried the owner's address with it. Relative,
the same folder is `?media=ABOUT+US`: shorter, and it says nothing about the machine or the account.
It is also what makes the jail hold by construction rather than by comparison. The value is JOINED
to `basePath`, so there is no "outside" to check for — only `..` could climb out, and a segment of
`..` is refused outright.
*/
const paramRelative = pathParam ? searchParams.get(pathParam) : null;
const paramPath =
paramRelative && !paramRelative.split('/').includes('..')
? `${basePath.replace(/\/$/, '')}/${paramRelative.replace(/^\/+/, '')}`
: null;
const currentPath = urlPath ? folderFromPathname(location.pathname) : pathParam ? (paramPath ?? basePath) : localPath;
const setCurrentPath = (path: string) => {
// Leaving a folder closes what was open in it. A pane shows a file from the folder you were in, so
// carrying it into the next one leaves the two disagreeing — the listing says one place, the pane
// another. Only the OVERLAY params go; anything else in the query (global UI state) is left alone,
// which is the distinction a wholesale reset kept getting wrong.
if (urlPath) navigate({ pathname: folderHref(path), search: withoutViewerParams(location.search) });
else {
else if (pathParam) {
/*
PUSHED, not replaced — so Back and Forward move between folders.
This was `replace` on the argument that browsing is not navigation worth a history entry. That
was wrong, and wrong in a way the URL itself gives away: the moment a folder is addressable, it
is a place, and Back meaning "the folder I was just in" is what anybody expects of a place. It
also matches `/files`, which pushes — an embedded browser behaving differently from the full one
is a difference nobody can see a reason for.
Reading is unchanged: `currentPath` is derived from the param, so a Back that rewrites the query
moves the browser with it and needs nothing here.
*/
setSearchParams((prev) => {
const next = new URLSearchParams(withoutViewerParams(prev.toString()));
// The base folder is the ABSENCE of the param, so a link to the top of an embedded browser is
// the plain screen URL and nothing accumulates for having gone nowhere.
const relative = path.startsWith(basePath) ? path.slice(basePath.replace(/\/$/, '').length + 1) : '';
if (!relative) next.delete(pathParam);
else next.set(pathParam, relative);
return next;
});
} else {
setLocalPath(path);
// Written here rather than in an effect on `currentPath`, so it records a NAVIGATION and not the
// initial render. An effect would fire once on mount with the restored value and persist it back,
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { getFileType, getThumbUrl } from './file-types';
import { useFileViewer } from './FileViewerContext';
/**
* Move between the files in a folder without going back to the listing.
*
* ── Which files ──
*
* Everything in the same folder the viewer can SHOW — images, video, audio, pdf, markdown, code, text.
* Not directories and not archives: opening one is an extraction rather than a view, so putting them in
* a strip of things you step through would offer a step that does something else entirely.
*
* ── It lists the folder itself ──
*
* The viewer is given one path and nothing else, and it is rendered by two different screens that reach
* it in two different ways. Listing the folder here means neither has to pass a collection, and the
* strip is right the moment a file is opened from anywhere — a link somebody sent, a search result, or
* the grid.
*
* ── The thumbnails are the file browser's ──
*
* Same `/thumb` route, same cache, same size the grid asks for. So a folder already browsed has a warm
* strip, and a HEIC in it works exactly where it works elsewhere rather than being a second thing to
* teach about formats.
*/
const STEPPABLE = new Set(['image', 'video', 'audio', 'pdf', 'markdown', 'code', 'text']);
export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
const { filePath, root, onNavigate } = useFileViewer();
const files = useFilesAPI(root || 'home');
const [entries, setEntries] = useState<DirEntry[]>([]);
const folder = filePath.slice(0, filePath.lastIndexOf('/')) || '/';
useEffect(() => {
let live = true;
void files
.listDir(folder)
.then((data) => {
if (live) setEntries(data.entries ?? []);
})
.catch(() => {
// A folder that cannot be listed simply gets no strip. The file itself is already open and
// readable; failing loudly here would break a working view over its navigation.
if (live) setEntries([]);
});
return () => {
live = false;
};
// `folder` only: re-listing on every file change would refetch the same directory for each step.
}, [folder]);
const siblings = useMemo(
() =>
entries
.filter((e) => e.type === 'file' && STEPPABLE.has(getFileType(e.name)))
.sort((a, b) => a.name.localeCompare(b.name)),
[entries],
);
const index = siblings.findIndex((e) => `${folder === '/' ? '' : folder}/${e.name}` === filePath);
const go = useCallback(
(delta: number) => {
if (index === -1 || !onNavigate) return;
const next = siblings[index + delta];
if (next) onNavigate(`${folder === '/' ? '' : folder}/${next.name}`);
},
[index, siblings, folder, onNavigate],
);
/*
Keyboard, on the window.
Ignored while somebody is typing — the viewer can be EDITING a file, and stealing arrow keys from a
text area would make the editor unusable. `isContentEditable` catches the rich cases that are not
inputs.
*/
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return;
if (event.key === 'Escape' && onClose) {
event.preventDefault();
onClose();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
go(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
go(1);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [go, onClose]);
// One file in the folder is not a carousel. The strip would be a row containing the thing already on
// screen, and two arrows that do nothing.
if (!onNavigate || siblings.length < 2) return null;
return (
<div className="flex shrink-0 items-center gap-2 border-t border-duck-dark/10 bg-background/60 px-2 py-2 dark:border-foreground/10">
<Caret side="left" disabled={index <= 0} onClick={() => go(-1)} />
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto">
{siblings.map((entry, i) => {
const path = `${folder === '/' ? '' : folder}/${entry.name}`;
const active = i === index;
return (
<button
key={entry.name}
type="button"
onClick={() => onNavigate(path)}
title={entry.name}
/* `ref` scrolls the active one into view as you step, so the strip follows rather than
being something you have to drag along behind you. */
ref={(el) => {
if (active) el?.scrollIntoView({ block: 'nearest', inline: 'center' });
}}
className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 transition-colors ${
active ? 'border-duck-teal' : 'border-transparent opacity-60 hover:opacity-100'
}`}
>
{entry.thumb ? (
<img
src={getThumbUrl(path, root)}
alt=""
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
) : (
<span className="flex size-full items-center justify-center px-1 text-[9px] leading-tight break-all text-duck-dark/60 dark:text-foreground/60">
{entry.name.slice(0, 18)}
</span>
)}
</button>
);
})}
</div>
<Caret side="right" disabled={index === -1 || index >= siblings.length - 1} onClick={() => go(1)} />
</div>
);
};
const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={side === 'left' ? 'Previous (←)' : 'Next (→)'}
className="shrink-0 rounded p-1 text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-25 dark:text-foreground/60 dark:hover:bg-foreground/5"
>
<Icon className="size-5" />
</button>
);
};
@@ -5,6 +5,14 @@ export type FileViewerContextValue = {
filePath: string;
fileName: string;
root: string;
/**
* Show a different file, without leaving the viewer. Absent when the host cannot express it.
*
* Each host writes it differently — the Files screen puts a name in `?view=`, an embedded panel puts a
* path — so the viewer asks rather than deciding, and a host that offers nothing simply gets no
* carousel.
*/
onNavigate?: (filePath: string) => void;
fileType: FileType;
content: string | null;
loading: boolean;
@@ -13,6 +13,8 @@ type FileViewerProviderProps = {
root?: string;
content?: string;
onOpenFile?: (filePath: string, root: string) => void;
/** Show a different file in the same viewer — what the carousel calls. */
onNavigate?: (filePath: string) => void;
onReplaceView?: (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => void;
autoPlay?: boolean;
children: ReactNode;
@@ -45,6 +47,7 @@ export const FileViewerProvider = ({
root = 'home',
content: directContent,
onOpenFile,
onNavigate,
onReplaceView,
autoPlay = false,
children,
@@ -171,6 +174,7 @@ export const FileViewerProvider = ({
<FileViewerContext
value={{
filePath,
onNavigate,
fileName,
root,
fileType,
@@ -2,6 +2,7 @@ import { X } from 'lucide-react';
import { FileViewerProvider } from './FileViewerProvider';
import { FileViewerHeader } from './FileViewerHeader';
import { FileViewerBody } from './FileViewerBody';
import { FileCarousel } from './FileCarousel';
type FileViewerViewProps = {
filePath: string;
@@ -9,10 +10,12 @@ type FileViewerViewProps = {
root?: string;
content?: string;
onClose?: () => void;
/** Enables the carousel. Without it the viewer shows one file and no strip. */
onNavigate?: (filePath: string) => void;
};
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content} onNavigate={onNavigate}>
<div className="h-full flex flex-col">
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
<FileViewerHeader />
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
<div className="flex-1 min-h-0">
<FileViewerBody />
</div>
{/* Below the content, outside the scrolling area — a strip that scrolled away with a long
markdown file would be a control you have to go looking for. Renders nothing when the folder
holds one viewable file, or when the host gave no `onNavigate`. */}
<FileCarousel onClose={onClose} />
</div>
</FileViewerProvider>
);
@@ -160,6 +160,19 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`;
}
/**
* The file browser's own thumbnail, at the size its grid asks for.
*
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
* format that needs a plugin to decode works here exactly where it works there.
*/
export function getThumbUrl(filePath: string, root?: string, size = 256): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getAudioTracksUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -1,4 +1,5 @@
export { FileViewerView } from './FileViewerView';
export { FileCarousel } from './FileCarousel';
export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody';
@@ -16,8 +16,22 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
/*
WHICH src has loaded, not whether one has.
The first fix reset a boolean in an effect on `src`, and that has a race a cached image wins every
time: the effect runs AFTER render, so the browser fires `onLoad` for an image already in cache
before the effect gets to clear the flag — and then the effect clears it, and `onLoad` never comes
again. The spinner stayed forever on exactly the files that were fastest.
Comparing the loaded src to the current one has no ordering at all. It is false the instant `src`
changes, true the moment that src's own load fires, and there is no window in which the two can be
written in the wrong order.
*/
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const loaded = loadedSrc === src;
const error = errorSrc === src;
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
@@ -77,6 +91,30 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
if (zoom <= 1) setOffset({ x: 0, y: 0 });
}, [zoom]);
/*
A NEW FILE IS A NEW LOAD, and everything about the last one goes.
`loaded` was set once and never cleared, which did not matter while a viewer showed one file for its
whole life. With a carousel it is the bug you see: the flag stays true, the opacity gate stays open,
and the browser goes on painting the PREVIOUS photograph until the next one has decoded. Stepping
through a folder looked like the image lagged a beat behind the strip — the strip was right and the
picture was stale.
Zoom, rotation and pan reset with it. They are a view of one image, and carrying 3x and 90 degrees
onto the next photograph means arriving somewhere nobody asked to be, in the corner of a picture
they have not seen yet.
*/
/*
Zoom, rotation and pan are a view of ONE image, so they reset when the image changes. These stay in
an effect because they have no race to lose: nothing else writes them during a load, and being one
render late is invisible.
*/
useEffect(() => {
setZoom(1);
setRotation(0);
setOffset({ x: 0, y: 0 });
}, [src]);
if (error) {
return (
<div className="flex items-center justify-center h-full">
@@ -104,8 +142,8 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
<img
src={src}
alt={fileName}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
onLoad={() => setLoadedSrc(src)}
onError={() => setErrorSrc(src)}
className="transition-transform duration-150 select-none"
draggable={false}
style={{
@@ -53,6 +53,15 @@ export function ViewerProvider({ children }: { children: ReactNode }) {
fileName={fileName}
onOpenFile={onOpenFile}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -83,6 +92,15 @@ export function EphemeralProvider({ children }: { children: ReactNode }) {
root={ephemeralRoot}
onOpenFile={onOpenFile}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -104,6 +122,15 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
root={ephemeral2Root}
autoPlay={ephemeral2Auto}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
+1
View File
@@ -66,6 +66,7 @@ export {
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
export {
FileViewerView,
FileCarousel,
FileViewerProvider,
FileViewerHeader,
FileViewerBody,
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import type { LayoutNode } from 'officerdev';
import type { LayoutNode, LayoutPanel } from 'officerdev';
import { __testing } from './useAuthoredLayout';
const { collectSizes, applySizes } = __testing;
@@ -12,7 +12,7 @@ const { collectSizes, applySizes } = __testing;
* way it can be wrong is silent, because a layout with the wrong numbers in it still renders.
*/
const panel = (id: string, appType: string): LayoutNode => ({ type: 'panel', id, appType });
const panel = (id: string, appType: string): LayoutPanel => ({ type: 'panel', id, appType });
const group = (id: string, children: { node: LayoutNode; size: number }[]): LayoutNode => ({
type: 'group',
@@ -127,3 +127,93 @@ describe('a repair that cannot stick must not be written', () => {
expect(JSON.stringify(afterRepair)).toBe(JSON.stringify(current));
});
});
/**
* Zoom, which was silently deleted for as long as only sizes were grafted.
*
* The failure was invisible rather than merely unsaved: the round trip produced a tree identical to the
* one already held, so `setValue`'s no-write guard dropped it and the header went on reading 100%. These
* tests are the round trip, at the level where it went wrong.
*/
describe('zoom survives the graft', () => {
const { collectZooms, applyZooms, graft } = __testing;
/** The user has zoomed the chat panel, as `setZoom` would leave it. */
const zoomed = group('root', [
{
node: group('lists', [
{ node: panel('hosts', 'proxy-manager-hosts'), size: 62 },
{ node: panel('certs', 'proxy-manager-certificates'), size: 38 },
]),
size: 66,
},
{ node: { ...panel('chat', 'proxy-manager-chat'), zoom: 1.2 }, size: 34 },
]);
test('collects only the panels that carry one', () => {
expect(collectZooms(zoomed)).toEqual({ chat: 1.2 });
expect(collectZooms(source)).toEqual({});
});
test('grafts onto the author structure, which never carries zoom itself', () => {
const merged = graft(source, zoomed);
expect(collectZooms(merged)).toEqual({ chat: 1.2 });
});
test('a zoom change is a real difference, so the no-write guard lets it through', () => {
const current = graft(source, source);
const next = graft(source, zoomed);
expect(JSON.stringify(next)).not.toBe(JSON.stringify(current));
});
test('sizes and zoom graft together, neither eating the other', () => {
const resized = group('root', [
{
node: group('lists', [
{ node: panel('hosts', 'proxy-manager-hosts'), size: 20 },
{ node: panel('certs', 'proxy-manager-certificates'), size: 80 },
]),
size: 66,
},
{ node: { ...panel('chat', 'proxy-manager-chat'), zoom: 0.8 }, size: 34 },
]);
const merged = graft(source, resized);
expect(collectSizes(merged)).toEqual({ root: [66, 34], lists: [20, 80] });
expect(collectZooms(merged)).toEqual({ chat: 0.8 });
});
/**
* `setZoom` deletes the field on reset rather than writing 1, so an absent id has to mean "no opinion"
* — otherwise Reset zoom would be unreachable on a screen whose author shipped a zoom of their own.
*/
test('a reset drops back to whatever the author said', () => {
const authored = group('root', [
{ node: { ...panel('a', 'x'), zoom: 1.4 }, size: 50 },
{ node: panel('b', 'y'), size: 50 },
]);
// Nothing stored: the author's 1.4 stands.
expect(collectZooms(graft(authored, authored))).toEqual({ a: 1.4 });
// The user overrides it.
const overridden = group('root', [
{ node: { ...panel('a', 'x'), zoom: 0.9 }, size: 50 },
{ node: panel('b', 'y'), size: 50 },
]);
expect(collectZooms(graft(authored, overridden))).toEqual({ a: 0.9 });
// The user resets — `setZoom` removed the field — and the author's value returns.
const reset = group('root', [
{ node: panel('a', 'x'), size: 50 },
{ node: panel('b', 'y'), size: 50 },
]);
expect(collectZooms(graft(authored, reset))).toEqual({ a: 1.4 });
});
test('a panel id that no longer exists is simply not applied', () => {
const stale = group('root', [{ node: { ...panel('gone', 'x'), zoom: 2 }, size: 100 }]);
expect(collectZooms(graft(source, stale))).toEqual({});
});
test('applyZooms returns the node by reference when nothing changes', () => {
const node = panel('a', 'x');
expect(applyZooms(node, {})).toBe(node);
});
});
+61 -9
View File
@@ -3,7 +3,9 @@ import { useCallback, useMemo } from 'react';
import { useDashboardState } from './useDashboardState';
/**
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose SIZES belong to whoever uses it.
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose VIEW belongs to whoever uses it.
*
* The view is sizes and zoom: the two things a `locked` screen still lets a person change.
*
* ── The bug this ends ──
*
@@ -20,18 +22,30 @@ import { useDashboardState } from './useDashboardState';
* first panel and SAVED, so the corruption survived every reload, every fix, and every restart. The
* row had to be deleted from Postgres, which is not a thing a user can do.
*
* ── The bug this ended a second time, and why zoom is here ──
*
* Sizes were the only thing grafted back, so `zoom` — which lives on the panel node, because the node is
* the thing already persisted — was collected by nothing and rebuilt from `source` every render. The
* result was a control that rendered, took the click, and discarded it: `setValue` re-derived a tree
* identical to the one it already had, the no-write guard below dropped it as a no-op, and the header
* went on reading 100%. Silent on every plugin screen, `/bots` and `/plugins`, and reported as "the zoom
* buttons do nothing".
*
* The lesson is the general one: anything the user owns has to be named here, because the default for a
* field this hook does not know about is not "passed through" — it is "deleted, quietly".
*
* ── The fix, and why it is a shape rather than a rule ──
*
* The structure comes from `source` on every render and is never read back from storage. Only sizes are
* The structure comes from `source` on every render and is never read back from storage. Only the view is
* persisted. So a stale structure cannot exist, a repair has nothing to write itself into, and a plugin
* that changes its layout reaches everybody on the next load — with no version to bump, no migration, and
* nothing for an author to remember.
*
* The user keeps the one thing that was theirs.
* The user keeps the things that were theirs.
*
* ── What is stored ──
*
* `source` with the user's sizes applied — a valid layout, because the storage validates one on both the
* `source` with the user's view applied — a valid layout, because the storage validates one on both the
* route and a CHECK constraint, and because a value that is always freshly derived from source cannot rot.
* Its structure is written and never read. That asymmetry is the whole design and is why it is safe.
*
@@ -43,6 +57,7 @@ import { useDashboardState } from './useDashboardState';
*/
type Sizes = Record<string, number[]>;
type Zooms = Record<string, number>;
const isGroup = (node: LayoutNode): node is LayoutGroup => node.type === 'group';
@@ -54,6 +69,22 @@ function collectSizes(node: LayoutNode, into: Sizes = {}): Sizes {
return into;
}
/**
* Every panel's zoom, by panel id — and only where one is actually set.
*
* `setZoom` DELETES the field on a reset to 100% rather than writing 1, so an absent id means "the user
* has no opinion" and the author's value stands. That is what makes Reset zoom reachable on a screen
* whose author shipped a zoom of their own.
*/
function collectZooms(node: LayoutNode, into: Zooms = {}): Zooms {
if (!isGroup(node)) {
if (node.zoom !== undefined) into[node.id] = node.zoom;
return into;
}
for (const child of node.children) collectZooms(child.node, into);
return into;
}
/**
* Put saved sizes back onto the author's structure.
*
@@ -78,22 +109,39 @@ function applySizes(node: LayoutNode, sizes: Sizes): LayoutNode {
};
}
/** Put saved zoom back onto the author's structure. A panel id nobody saved keeps whatever `source` says. */
function applyZooms(node: LayoutNode, zooms: Zooms): LayoutNode {
if (!isGroup(node)) {
const saved = zooms[node.id];
if (saved === undefined || saved === node.zoom) return node;
return { ...node, zoom: saved };
}
return {
...node,
children: node.children.map((child) => ({ ...child, node: applyZooms(child.node, zooms) })),
};
}
/** The author's structure wearing the user's view. The only way either half of this hook builds a tree. */
const graft = (source: LayoutNode, stored: LayoutNode): LayoutNode =>
applyZooms(applySizes(source, collectSizes(stored)), collectZooms(stored));
/**
* Same shape as `useDashboardState`, so a screen swaps one call and changes nothing else.
*
* `setValue` accepts a whole layout because that is what `WorkspaceView` reports — a resize hands back the
* entire tree. Everything but its sizes is discarded here, which is precisely the point: the view is free
* to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
* entire tree. Everything but the user's view is discarded here, which is precisely the point: the view is
* free to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
*/
export function useAuthoredLayout(key: string, source: LayoutNode) {
const stored = useDashboardState<LayoutNode>(key, source);
const value = useMemo(() => applySizes(source, collectSizes(stored.value)), [source, stored.value]);
const value = useMemo(() => graft(source, stored.value), [source, stored.value]);
const setValue = useCallback(
(update: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => {
const next = typeof update === 'function' ? update(value) : update;
const merged = applySizes(source, collectSizes(next));
const merged = graft(source, next);
/*
A write that changes nothing is dropped, and this is not an optimisation — it is what stops an
@@ -109,6 +157,10 @@ export function useAuthoredLayout(key: string, source: LayoutNode) {
panel keys, so every render of `/browser` produced a repair. Shipping the sizes-only hook turned a
silent, harmless fallback into a hard crash on that screen.
It is also why a field this hook forgets to graft is INVISIBLE rather than merely unsaved: the
change round-trips to the same tree, compares equal, and is dropped here. That is exactly how zoom
was lost.
Comparing the serialised value is enough — these trees are small, this runs only on a real change,
and it is the same check `setValue` would otherwise force React Query to make against a new object.
*/
@@ -123,4 +175,4 @@ export function useAuthoredLayout(key: string, source: LayoutNode) {
}
/** The tree walking, exposed for its test. Not part of the hook's API — import the hook. */
export const __testing = { collectSizes, applySizes };
export const __testing = { collectSizes, applySizes, collectZooms, applyZooms, graft };
+8
View File
@@ -0,0 +1,8 @@
---
theme: integrations
heading: 'Integrations — external tools worth wrapping as a plugin'
kind: theme
---
Third-party self-hosted tools evaluated for a plugin, with what was actually checked (license,
activity, self-hosting shape, credential cost) rather than a name someone liked.
@@ -0,0 +1,49 @@
---
id: integrations/openseo-a-plugin-candidate
title: 'OpenSEO — a strong plugin candidate, checked rather than assumed'
theme: integrations
status: open
created: 2026-08-27
date_source: stated
order: 1
---
**OpenSEO (`every-app/open-seo`) — worth building a plugin around.** Asked to be evaluated 2026-08-27;
checked against gitea rather than taken on the README's word.
**What it is:** a self-hosted, open-source alternative to Semrush/Ahrefs — keyword research, rank
tracking, competitor insights, backlinks, site audits, AI visibility. MIT licensed, TypeScript,
**14.4k stars, 1.7k forks, pushed 3 days ago** (verified via `gh repo view`, not the README's own
framing) — a real, active project, not an abandoned demo.
**Why it fits this platform specifically, more than most tools would:** it ships **its own MCP server
and Agent Skills**, built explicitly to be driven by "Claude Code, OpenClaw, or Hermes." That is
Officer's whole plugin thesis already built by someone else — most candidates need Officer to invent
the agent-facing surface; this one already has one.
**The self-hosting shape is the easy case, verified from `compose.yaml` and the docs, not assumed:**
- **One container, no separate database.** Cloudflare D1 (SQLite) by default — Postgres is opt-in only
for installs that outgrow it (`docs/LOCAL_POSTGRES.md`). Same shape as Jellyfin, Gitea, Transmission:
a single sidecar, nothing extra to provision.
- **One required credential**: a DataForSEO API key, paid pay-as-you-go DIRECTLY to DataForSEO — not a
subscription to OpenSEO itself unless using their hosted version. Two more are optional
(OpenRouter for its own AI features, Google OAuth for Search Console). Real cost, worth saying
plainly: this is free software over a metered API, not a free service.
- **Self-hosting runs `AUTH_MODE=local_noauth` by design** — their own docs say explicitly _"only
expose it behind your own auth-protected reverse proxy, tunnel, or private network."_ That sentence
describes exactly what an Officer plugin already is: mounted behind Officer's own permission gate,
never exposed on its own. This is the ordinary case for a plugin here, not a special risk to design
around.
**What a plugin would actually need to add, and it may be less than usual:** because OpenSEO already
runs its own MCP server, the plugin's job is closer to credential plumbing and a UI surface
(`osDependencies`-style container/sidecar, the DataForSEO key in the per-user/server credential store,
a panel embedding or proxying its web UI) than inventing new MCP tools from scratch — most of the
agent-facing work is already done upstream.
Open, for whoever picks this up: whether Officer's agents call OpenSEO's own MCP endpoint directly
(simplest, if reachable over whatever network the plugin runs on) or Officer wraps it as
Officer-native MCP tools for consistency with how other plugins expose theirs; and where the container
runs given `todo/infra/opm-officer-s-own-process-manager` is heading toward the Docker network
specifically so container-backed dependencies like this one need no published host port.
@@ -0,0 +1,47 @@
---
id: known-bugs/officer-blocks-its-event-loop-parsing-transcripts
title: 'Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it'
theme: known-bugs
status: open
created: 2026-08-29
date_source: stated
order: 7
---
**Found 2026-08-29 while chasing "Minesweeper is laggy today".** It is not a Minesweeper bug. The game's
display is relayed through officer, and officer periodically stops answering anything for up to a second.
**The measurements, in order.** Press-to-paint at x11vnc's own socket: **median 29ms, max 35ms**, stable
even with a 1.5s idle gap before each click. Through the sidecar's own `/ws` relay: **median 29ms, max
43ms** — so the sidecar adds nothing. In the browser, where it is actually felt: **35ms to 200ms+, varying
per click**. All of the variance is introduced between the sidecar and the browser, and the only thing
there is officer.
Officer answering a trivial unauthenticated 401 on `/api/health`, 30 samples: **median 1ms, p90 2ms, max
490ms.** The loop is fine until it is blocked outright.
**What blocks it.** `summarizeTranscript` (`api/chat/claude-sessions.ts`) full-reads and `JSON.parse`s a
whole transcript to build a session-list row. Timed against the largest on this machine — 196 MB, 70,137
entries: `readFileSync` **323ms**, split + parse every line **541ms**, **864ms blocking, per call**.
It is cached, and the cache cannot help. The key is mtime, and the comment above it says why that was
believed to be exact: _"Claude only ever appends"_. But a **live** session appends every few seconds, so
its mtime moves, the entry misses, and the whole 196 MB is re-read and re-parsed — for a row in a list.
The busier the agent, the more often. `468 MB across 174 transcripts` here, and the biggest one belongs to
a commander that has been running all day.
**Why it reads as a Minesweeper regression.** Nothing in the plugin changed (last commit two days ago), and
the same cost was invisible when that transcript was 10 MB and this took ~45ms. It scales with transcript
size, so it arrives gradually and then all at once.
**Scope is much wider than the game.** Every HTTP request, every chat socket frame and every relayed plugin
websocket shares that loop. The game is just the one surface where a 500ms stall is impossible to miss.
**The fix the file argues for itself.** _"Claude only ever appends"_ is exactly what makes incremental
summarisation correct: keep the running summary keyed on `(path, size)` and consume only the bytes appended
since last time, instead of re-reading from zero. Turns ~864ms into the cost of the new tail. `listClaudePwds`
already does a bounded head read for `cwd` (`claude-sessions.ts`, "a bounded head read"), so the precedent
for not reading the whole file is in the same module.
Not attempted here: it is a hot path shared by every surface, and the right shape — incremental parse versus
moving summaries out of the request path entirely — is an architecture call rather than a patch.