Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b71522ed | ||
|
|
73a9b0bf51 | ||
|
|
882cb67f7a | ||
|
|
2738f799f2 | ||
|
|
82d24a8feb | ||
|
|
3d15f9fc8e | ||
|
|
990078efce | ||
|
|
53f3ca33b5 | ||
|
|
eaecf563ff | ||
|
|
321cac2e80 | ||
|
|
0ea4b822ae | ||
|
|
1da58e5231 | ||
|
|
cad0ea3c9c | ||
|
|
03638a7f62 | ||
|
|
000df7fe45 | ||
|
|
39c1122651 | ||
|
|
44f16fedee |
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.29.03",
|
||||
"version": "2026.08.29.04",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
+10
-2
@@ -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) => {
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
+34
-17
@@ -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]">
|
||||
|
||||
+46
-37
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user