A collaborative coding-agent orchestrator for atproto radl.app

improve editors #89

Closed disnetdev.com
targeting main
Closed opened by and assigned to disnetdev.com
targeting main

CodeMirror markdown editing, and images as PDS records #

Both halves of the goal, built on the approved plan. Branch radial/impl-da42ce2a5b79, commit d33626e5facfc569e6327bb57c77138d2482efc1.

The wire type #

packages/lexicons/lexicons/com.disnetdev.radial.image.json — a tid-keyed record with a required blob and createdAt, and optional bounded alt and name. The blob accepts four raster containers (image/png, image/jpeg, image/webp, image/gif) with a 10 MB ceiling, matching the existing artifact blob limit. SVG is deliberately absent: through an <img> it is still a document, with script, external references and an unbounded decode.

Both values are read back out of the lexicon at runtime (IMAGE_LIMITS in @radial/core), so the upload form, the writer, the OAuth scope and the validator cannot check four different numbers.

The locator, and what a reader may fetch #

packages/core/src/image.ts is the pure protocol half: format, parse, catalogue, blob URL. A body refers to a picture as ![alt](radial-image:<the image record's at:// URI>) — a record URI, never an address — for two reasons stated in docs/design.md §4:

  • atproto reclaims a blob no record references, so the record is what makes an upload storage. The record is therefore written before the markdown is inserted, and a failure between the two is reported as "the image was uploaded but not saved, nothing was inserted" rather than as a partial success.
  • a getBlob?did=… URL pins whichever PDS the author was on that day; a record URI names the author, and the DID is resolved to its current PDS at read time.

Parsing is strict and closed: only an image-collection at:// URI with a DID authority and one key-safe path segment — no query, no fragment, no userinfo, no second segment, no other scheme. The blob CID always comes from the record that was read and validated, never from the markdown, so this is not an arbitrary-fetch primitive with a Radial scheme painted on it.

The fold #

MaterializedIndex.images holds every trusted member's image records in the usual (createdAt, uri) order, bound to a space by membership exactly as an agent record is. It exists so a reader can resolve a locator without a second round trip, and nothing downstream reads it. The golden scenario now carries three image records (two trusted, one from the since-removed member); materializer.test.mjs asserts they are order-independent, that the removed member's is dropped like everything else they wrote, that none of them collects an "unresolved" diagnostic — and, as the real gate, that removing every image record leaves the rest of the index and the convergent digest byte-identical.

Writing #

  • radial image create --blob JSON [--alt] [--name] in the sidecar: the record half only. It reads no files — a browser has none, and teaching the single shared write path to take binary would have meant teaching the turn socket to take it too. The socket's allowlist already refuses it, which is asserted.
  • Credentials.uploadBlob in the UI, backed by RepoWriter.uploadBlob rather than a second HTTP call. uploadImage() in write.ts validates locally, uploads, writes the record through the normal runCli path, folds the acknowledged record optimistically, and returns the URI plus the markdown.
  • File checks are local and happen before any network I/O: the container's own magic number (not the browser's filename-derived MIME type), then the lexicon's allowlist and ceiling. A JPEG named .png uploads as a JPEG; an SVG named .png is refused.
  • RADIAL_WRITES gains the image collection, and the scope gains blob?accept=image/gif&accept=image/jpeg&accept=image/png&accept=image/webp — the four formats and no more, so an SVG is refused by the grant as well as by the writer. Existing sessions get the usual "sign out and sign in again" scope-recovery message until reauthorized.

The editor #

MarkdownEditor.svelte is the only integration point, over editor.ts (the whole CodeMirror assembly, behind one dynamic import()). It keeps the contracts a textarea gave for free:

  • the bound value is a plain string, so $state, draft-holding, trim(), submit-disable and the command arguments above it are unchanged;
  • it degrades to a textarea — both are in the DOM, one hidden, so the swap carries the text, the caret and the focus; if the chunk never arrives the field still works;
  • Escape leaves the field and ⌘↵ submits, as explicit keymaps, because a contenteditable inherits neither. Everything else (Enter, Tab, arrows, IME) is left to CodeMirror.
  • disabled reconfigures a compartment rather than rebuilding, so a busy state does not drop the undo history; the view is destroyed on unmount.

Migrated: NewGoal, Composer, UnitDetail's reply, NewRequest, AskReview, AskAnswer, NewType, Verdict. Titles, paths, line numbers, output descriptions and the space description stay native controls — they are not markdown. Image upload is enabled on goal bodies, messages and replies, and request briefs; left off for artifact-type templates and review findings, where what a picture means downstream has not been specified. Submit is held while a file is in flight.

Reading #

Runs.svelte checks the locator before safeImage, which still refuses it, and renders through RadialImage.svelte. Resolution (image-resolve.ts) is one cached promise per record, dropped when the tab changes space; it renders through an ordinary public blob URL in an <img src> with loading="lazy" and referrerpolicy="no-referrer" — no object URLs, no fetched bytes through the app. A locator that resolves to nothing draws the existing placeholder with the reason in its title. External https: images and every existing refusal (data:, javascript:, relative) are unchanged.

Downstream #

A turn's bundle carries prose verbatim, so a locator reaches an agent as text. bundle.md now ends with an ## Images section listing each image the bundle's prose refers to, once, with the two steps that turn a record URI into bytes — so a screenshot pasted into a brief does not disappear from turn context.

Tests and checks #

pnpm codegen (clean git diff after), pnpm typecheck, pnpm lint, pnpm test, pnpm build all pass. 194 core, 32 atproto, 20 ingest, 51 sidecar, 544 daemon, 421 + 25 UI.

New coverage: locator round trips and a list of malformed/hostile forms that must not resolve; MIME sniffing and the upload refusals; image-record permutation and non-effect on the fold and the digest; image create including the turn-socket refusal; the OAuth blob scope; a UI write integration (upload → record → prose record → cold re-read → out-of-order ingestion) against fake-pds.ts, extended with uploadBlob and getBlob; the resolver against that same PDS; and a new component suite that mounts the editor for real.

Two testing changes worth knowing:

  • vitest is now two projects — pure (Node, no DOM) over src/**/*.test.ts, and components (jsdom, svelte's browser condition) over src/**/*.svelte.test.ts. jsdom is a new dev dependency of @radial/ui only.
  • test/browser-bundle.test.mjs gains a rule rather than a byte budget: every @codemirror/@lezer import under src must be erased or dynamic, and only src/lib/editor.ts may reach for the library. The built bundle bears this out — the eager chunk is 6 kB and CodeMirror is 196 kB in a chunk nothing on the read path loads.

Docs #

docs/design.md §4 (image-record ownership, locator resolution, blob retention, allowed formats, the explicit non-effect on the fold, availability following the author's PDS), packages/lexicons/README.md (additive compatibility and the blob-reference rationale), packages/ui/README.md (the new modules and which surfaces take images), DESIGN.md (the editor as a field rather than a code editor), CLAUDE.md/AGENTS.md (the two vitest projects), and the RepoWriter.uploadBlob comment that said Radial uploaded no blobs of its own.

Remaining risks #

  • Not verified against a live PDS. Every claim above is held by the automated suite against fake-pds.ts; the plan's manual acceptance list (a real upload, a reload in a signed-out browser, a moved PDS, keyboard-only image insertion, narrow-screen layout) has not been walked.
  • Orphaned blobs are accepted for v1. An upload whose record write fails is reported honestly and left for the PDS to reclaim; an image record whose markdown is later deleted is left standing, because records are immutable by convention and reclamation is a retention design.
  • Old sessions must re-authorize before they can add an image — the scope grew.
  • One more collection to poll. radialCollections now includes images, so a cold sync costs one extra listRecords per member repo.
  • jsdom is not a browser. The component suite mounts CodeMirror with layout stubbed out (jsdom-layout.ts), so it proves the wiring — value, reset, disabled, teardown, keymaps, selection insertion, paste/drop — and proves nothing about measurement, mobile keyboards or real IME.

Radial artifact

This pull request has no versions submitted yet.