DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/loomworks-composer-upload-port-cr-v0_2.html
LoomworksChange Requestv0.22026-05-28Awaiting Re-Approval

Composer Upload Port

Mounting file upload on the in-engagement composer through a shared upload hook, and closing the composer's draft-persistence gap. Operator-Layer-only; no engine change.

Version   0.2
Date   2026-05-28
Status   Approved at v0.1; amended after Step 0. Awaiting re-approval of the amendment before Step 1.
Shape   Shape 2 — shared hook.
Informs from   loomworks-composer-upload-port-scoping-note-v0_1
Repo touched   Operator Layer (loomworks) only

What changed from v0.1

Two changes, both consequences of the Step 0 pre-flight inspection.

1. Draft persistence added to scope. Step 0 confirmed CenterPane has no persisted-draft mechanism: type in the in-engagement composer, navigate away, and the text is lost — while the older ChatView surface preserves it. Since CenterPane is the surface being made primary, this parity gap is closed in this work rather than deferred. The mechanism already exists and is proven (src/lib/composer-draft.ts, used by ChatView); the work is wiring it into the composer, identically. Construction question 3 is resolved by this: the two surfaces become symmetric, both passing clearDraft into the upload hook, rather than the composer carrying an opt-out.

2. The two-DisplayTurn divergence recorded and handled. Step 0 found ChatView and CenterPane declare two different turn types under the same name DisplayTurn (ChatView's extends the wire type and carries classified_intent and fromHistory; CenterPane's is a hand-rolled subset with neither). The hook design already anticipated this: the hook does not construct turns — it calls injected append callbacks each surface supplies, so it stays agnostic to both turn types. v0.2 makes this an explicit build constraint.

The build sequence is revised so uploads and draft persistence are separate steps with separate commits — draft persistence proven working on the composer before the upload mount touches the same surface. This keeps "implement now" from tangling two changes.

Plain-language summary

This Change Request specifies the work to let an Operator bring files into an engagement through the in-engagement composer (CenterPane) — the surface they actually work in — instead of only through the older ChatView surface. The composer currently shows a disabled "Attachments coming soon" button, which violates the seed's "only show what is available" commitment; this work makes that button live. It also closes a parity gap the inspection surfaced: the composer loses a typed draft on navigation, while the older surface preserves it; this work gives the composer the same draft persistence.

The upload shape is Shape 2: the upload logic that currently lives inside ChatView (a roughly hundred-line orchestration) is extracted into one shared hook. ChatView is refactored to use the hook with its behaviour preserved exactly. The composer then uses the same hook, so both surfaces share one copy of the upload logic rather than maintaining two.

One architectural question is settled with its evidence: the composer's upload goes direct to the engine via the existing postUpload, because the Operator Layer has no backend tier of its own — it is a pure frontend, and every call (conversation included) already goes to the engine. There is no "gateway" alternative to build.

The decision needed from the Operator now is re-approval of this amendment (the added draft-persistence scope). Nothing executes until re-approved.

In scope: the upload hook extraction, the ChatView refactor, the composer upload mount, the disabled-button removal, draft persistence for the composer, and test coverage. Out of scope, and tracked as queued-directions §15.3: the assertion-grain engine gap and two documentation corrections. Operator-Layer-only; no engine change and no new backend route.

The settled decision — the upload seam

The scoping note left one architectural question open: does the composer's upload go direct to the engine, or through an Operator Layer gateway route? Evidence gathered during scoping (architecture spec v0.4 read plus the Operator Layer call-pattern survey) settles it, and corrects the premise the question rested on.

The premise was that the composer talks to two backends — the Operator Layer for conversation, the engine for upload. That is false. The Operator Layer (loomworks) is a pure Next.js frontend with no server-side API surface: no route handlers, no proxy, one base URL pointing at the engine. The route /operator/converse the composer uses for conversation is not an Operator Layer route — it is the engine's orchestration router, mounted at the /operator/ prefix inside the same process that serves /engagements/{id}/uploads. Both already terminate at the engine.

So there is no second backend, and the "gateway" option would mean building an Operator Layer backend tier that does not exist anywhere today — with no spec basis (the words "gateway" and "chokepoint" do not appear in the spec; "Operator Layer" is defined there as a frontend surface, read-only relative to the engine) and no code basis. The spec assigns the governed boundary to OVA and FORAY — both engine-side, both seam-and-stub, neither the Operator Layer. The upload endpoint already lives in the engine where those seams attach.

Resolution: the composer's upload uses the existing postUpload, targeting the engine at /engagements/{id}/uploads. This conforms to the only established pattern in the repo and sits at the layer where OVA and FORAY are designated to attach when they harden.

A wording correction for the scoping note follows and is tracked: the note's "cross-layer seam" phrasing is imprecise. It is not cross-layer (two backends); it is cross-path-family within one engine (the orchestration /operator/* family for conversation, the substrate /engagements/* family for upload). Documentation correction for the scoping note's next revision.

The shape — Shape 2

The visible upload pieces are already shared components: UploadAttachmentButton, UploadResultCard, postUpload (@/lib/uploads), and filterPlatformSystemFiles (@/lib/folder-upload) — all confirmed present and importable by Step 0. Only the orchestration that sequences them — handleUpload, inlined in ChatView at lines 373-468 (Step 0 confirmed) — is stuck in ChatView. This work extracts that orchestration into one hook.

The hook contains the full upload sequence: filter platform junk and guard against empty / busy / no-engagement; set the busy gate; capture and clear the typed draft; if the typed draft was non-empty, append it as an Operator turn so it does not vanish; append an attachment-marker turn; call postUpload; on success append a Companion acknowledgment turn and write the per-file results; handle the 401 (silent) and other-error (banner) paths; release the busy gate in finally.

The hook is turn-type-agnostic (build constraint, from Step 0). Because the two surfaces declare different DisplayTurn types, the hook must not construct turns or read turn fields. It calls injected appendOperatorTurn(text) → id and appendCompanionTurn(text) → id callbacks; each surface constructs its own turn in its own shape and returns the new turn's id. The hook never names a turn type and never reads classified_intent or fromHistory. Step 0 confirmed neither field is needed by the upload path.

The surfaces provide the state plumbing the hook drives. They differ — ChatView uses thinking, CenterPane uses sending (Step 0 confirmed both); ChatView has a fat per-turn attachments state, CenterPane has none — so the hook is parameterised over the operations it needs rather than owning that state. This keeps the composer narrow.

Hook interface

Confirmed against Step 0 findings. src/hooks/ does not exist today; the repo convention is to colocate hooks next to their consumer (useUnseenCount.ts, useVoiceListening.ts). The hook is shared by two consumers, so it does not belong beside either one. Default: create src/hooks/useEngagementUpload.ts as a new shared-hook location. If the Operator prefers to honour the colocation convention instead, name the location; otherwise the new directory stands.

The hook accepts: the engagement id (or null); a busy flag and its setter (thinking / sending); a way to read the current draft and clear the in-memory draft; a persisted-draft clear (now supplied by both surfaces — see construction question 3); appendOperatorTurn(text) → id and appendCompanionTurn(text) → id; writeResults(turnId, perFile); and an error setter. It returns one function, upload(files).

In scope

  1. Extract the upload orchestration from ChatView into the shared, turn-type-agnostic hook.
  2. Refactor ChatView to consume the hook, preserving its current behaviour exactly. Principal regression surface (see risk).
  3. Wire draft persistence into CenterPane using the existing src/lib/composer-draft.ts, identically to ChatView: restore-on-mount, debounced (300ms) write-on-change, clear-on-commit (on send and on upload-fire), keyed by engagement.project_id.
  4. Mount the live UploadAttachmentButton on CenterPane, wired to the hook; remove the disabled "Attachments coming soon" placeholder (CenterPane lines 564-574, Step 0 confirmed — a raw <button>, not the component).
  5. Add CenterPane's minimal per-turn results channel and render UploadResultCard from it (construction question 2). Drive the in-flight progress affordance from sending.
  6. Test coverage for the hook, the composer draft persistence, and the composer upload mount; full vitest green.

Construction questions — resolved

  1. (Seam.) Direct-to-engine via existing postUpload. Resolved above.
  2. Composer results-rendering channel. The hook writes per-file results through the injected writeResults(turnId, perFile) callback; CenterPane holds a minimal Record<turnId, perFileResults> and renders UploadResultCard from it. ChatView keeps its existing fat attachments state and wires writeResults to it. UploadResultCard confirmed by Step 0 to have no ChatView coupling.
  3. Persisted draft — resolved by the v0.2 scope change. No longer optional. CenterPane gains the same composer-draft.ts persistence as ChatView (scope item 3), so both surfaces pass clearDraft into the hook. Symmetric; no opt-out path.
  4. Typed-message preservation. The hook owns the Phase 60 Sub-arc Finding #4 behaviour. Both surfaces get parity via the injected append. No per-surface logic.
  5. Frontend test coverage. Confirm/extract existing vitest coverage of ChatView's upload path; add unit coverage for the hook; add coverage for CenterPane draft persistence (restore, write, clear-on-send, clear-on-upload) and the CenterPane upload mount. Full suite green before close.

Build sequence

Each step is its own commit. Uploads and draft persistence are isolated steps so a regression is attributable to one and reversible without losing the other.

Acceptance conditions

Risk

Two regression surfaces, deliberately isolated by step. Step 1 touches ChatView's working, Operator-verified upload path (exercised end-to-end 2026-05-27); behaviour preserved exactly, guarded by vitest (Step 4) and manual re-verification (Step 5). Steps 2 and 3 both touch CenterPane's composer — draft persistence and the upload mount touch the same typing path; isolating them into separate commits means a regression is attributable to one and reversible without losing the other. Step 2 proves draft persistence in isolation before Step 3 adds upload. The eight engine upload-test fixtures guard the engine side and are unaffected; Step 4 adds the frontend coverage this work needs.

Out of scope

Tracked as first-class queued-directions §15.3 entries; named for boundary clarity; none gate this work.

Phase identifier and tagging

No phase number assigned; the next queue slot (Phase 61) is spoken for by narration multi-pathway, and the upload-pathway phase-identifier reconciliation is parked in manifest v0.43 §5. Tagging at close is Operator-Layer-only (no engine tag). Tag name and any phase-number assignment are an Operator decision at close.

DUNIN7 · Loomworks · Composer Upload Port · Change Request · v0.2 · 2026-05-28