PR: #596: feat(brief): EditableList primitive + editable Key Instruments [Story-2355] by @ankitmaheshwari-com
View PR →

Story-2355: EditableList — the list kind, and Key Instruments as its first consumer

The Brief's Key instruments rail was a wall: Vega listed the documents it found, and counsel could do nothing about it. If the framer missed the supplemental agreement, the missing instrument stayed missing. Worse — a matter with no instruments rendered nothing at all (the block was wrapped in instruments.length > 0 ? … : null), so the first real matter of a new firm showed a rail with a hole in it and no way to fill it.

This Story builds the list kind of the Editable Surface family (EditableList, frozen vocabulary §3) and makes Key instruments its first live consumer: add, edit in place, remove — instantly, with rollback.

The idea in one line

The row IS the value. Each item is an EditableSurface (plain, single-line) — so clicking an instrument drops the caret in it, blur commits it, and clearing its text removes it, because an empty row is no row. Add, edit, and remove all hand the caller the whole next array through one onSave.

The gate that had to pass first

The ticket made the data contract a hard gate: if the functions write path did not accept key_instruments, this Story stops and files the gap. It does accept it.

Gate: PASS. The Brief's prose fields already write through PATCH /app/matters/{id}/frame with { fields: { <field>: <value> } }, whose contract is "FrameOutput flat field names mapped to their replacement values — full-value semantics per field". In pramaan-functions, key_instruments is a first-class FrameOutput field (key_instruments: list[str] on the frame mirror, listed in frame_output.v1.fields.json), and it is not one of the two meta fields excluded from EDITABLE_FRAME_FIELDS. So a full-array write of plain strings is exactly what the endpoint wants. No functions child issue needed.

Two consequences fall straight out of the contract:

The primitive

<EditableList
  items={string[]}                    // the committed items (caller-owned, optimistic)
  onSave={(next: string[]) => void}   // add | edit | remove — ALWAYS the whole next array
  ariaLabel="Key instruments"          // required: editable regions announce themselves
  addLabel={t("addInstrument")}        // the CALLER resolves the i18n key — never a literal inside
  placeholder={t("instrumentPlaceholder")}
  readOnly={false}                     // permission gate: no add, no remove, rows read-only
  itemProvenance={(item, i) => …}     // per-item seam → the shared EditableSurfaceMarker
  itemIcon={<FileText …/>}
/>
ActionHow counsel does itWhat lands on the wire
Add "Add an instrument" opens a draft row with the caret already in it; type; Enter or blur. [...items, "Arbitration Agreement, cl. 14"]
Edit Click the row's text; blur commits (Escape reverts, as on every Editable Surface). the array with that index replaced
Remove The row's ✕ (hover / keyboard focus) — or clear its text. the array without that index
Nothing Open the draft row, type nothing, click away (or Escape). no write. An empty row never becomes an empty item.

Why the add row needed one new prop on EditableSurface

"Click Add, then click again to actually type" is not an affordance, it is a chore. The draft row must land the caret on mount — so EditableSurface gained a single autoFocus prop, forwarded to TipTap's own autofocus option. It is documented as belonging to a surface that only exists because counsel asked to write — never to a surface that is merely displaying a value, which would steal focus on page load.

Optimism: the row lands before the network does

AGENTS.md is blunt about this: a write that waits for the round-trip reads as lag. The Brief's frame edit lives in the TanStack cache (useAppGetMatterBrief), so it takes mechanism 1 — onMutate patches the cached Brief, onError restores the snapshot and toasts, onSettled invalidates to reconcile with server truth.

const edit = useAppEditMatterFrame<{ previous?: MatterBriefView }>({
  mutation: {
    onMutate: async (variables) => {
      await qc.cancelQueries({ queryKey: key });
      const previous = qc.getQueryData<MatterBriefView>(key);
      qc.setQueryData(key, { ...previous, frame: { ...previous.frame,
        frame: { ...previous.frame.frame, ...variables.data.fields },
        contentFormat: { ...previous.frame.contentFormat, ...(variables.data.contentFormat ?? {}) },
      }});
      return { previous };                       // ← the rollback path
    },
    onError: (_e, _v, context) => { qc.setQueryData(key, context.previous); toast.error(t("saveFailed")); },
    onSettled: () => qc.invalidateQueries({ queryKey: key }),
  },
});

Note where this went: on the shared edit mutation, not on a second list-only copy of it. Every frame field routes through that one mutation, so one onMutate makes them all instant — the root-cause fix, and a smaller diff than a bespoke optimistic path per caller. (The prose blocks' components are untouched; they simply inherit the instantness they were already owed. Their contentFormat declaration is carried into the optimistic patch too, so a Markdown field never flashes as literal asterisks in the optimistic window.)

The empty state is the point

The old block rendered only when instruments already existed. That is the exact failure the verification doctrine warns about — the zero-row first run is the one nobody looks at, and it was the one that was broken. The block now always renders: with no items, the add affordance is the empty state, and a fresh matter's instruments are counsel's to name.

Provenance and permission, inherited not reinvented

What was verified, and how

Storybook (real browser, driven): the seven states ship under Shared/EditableList — empty (add-first), populated, add flow, remove, read-only, provenance-marked, rollback-on-error. The add flow was driven in a real browser, not just eyeballed: clicking "Add an instrument" produces a draft row that reports [active] in the accessibility tree (autofocus works), typing + Enter commits the full next array, and the ✕ removes the row. The provenance story renders the dotted PROPOSED tag on the marked item.
Headless: editable-list.test.tsx — rows announce under the group label; remove commits the WHOLE next array; the add affordance opens a draft row from an empty list; read-only has no add and no ✕; a canonical item offers no ✕. pnpm lint (0 errors), pnpm test (1237 passing), pnpm build, tsc --noEmit, and pnpm storybook:check (drift + build) are all green.
Named honestly (verification doctrine): the live Brief wiring — the real PATCH …/frame round-trip against staging, and the optimistic rollback firing on a real server rejection — is not proven in this run. The contract gate is proven by reading the functions source (key_instruments ∈ EDITABLE_FRAME_FIELDS, typed list[str]), and the UI is proven in Storybook. The end-to-end staging pass on a real matter (add → reload → the instrument is still there) is the remaining seam, and it is a staging verification, not a code change.

Files changed

9 file(s), +550 / -19 lines

Path Role + -
messages/en.jsonConfig+4-0
messages/hi.jsonConfig+4-0
src/components/matter-detail/brief/matter-brief-page.tsxComponent+44-1
src/components/matter-detail/brief/matter-brief-sections.tsxComponent+26-17
src/components/shared/editable-list.stories.tsxComponent+195-0
src/components/shared/editable-list.test.tsxComponent+95-0
src/components/shared/editable-list.tsxComponent+159-0
src/components/shared/editable-surface.tsxComponent+8-0
tests/setup.tsTest+15-1

Files

FileWhat changed
src/components/shared/editable-list.tsxnew — the list kind
src/components/shared/editable-list.stories.tsxnew — seven states under Shared/EditableList
src/components/shared/editable-list.test.tsxnew — the headless contract
src/components/shared/editable-surface.tsxautoFocus prop (the draft row's caret)
src/components/matter-detail/brief/matter-brief-sections.tsxKey instruments → EditableList; always rendered
src/components/matter-detail/brief/matter-brief-page.tsxoptimistic frame edit + rollback + toast; saveList
messages/en.json, messages/hi.jsonremoveItem, addInstrument, instrumentPlaceholder, saveFailed