Version. 0.2
Date. 2026-05-28
Status. Working draft. Approved at v0.1; amended to v0.2 after Step 0 pre-flight inspection. Awaiting re-approval of the amendment before Step 1 execution.
Shape. Shape 2 — extract the upload orchestration into a shared hook both surfaces consume.
Informs from. loomworks-composer-upload-port-scoping-note-v0_1.
Repo touched. Operator Layer (loomworks) only. No engine change.
Two changes, both consequences of the Step 0 pre-flight inspection.
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.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 rather than an implicit one.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.
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 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 that 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 (capability-scoping, "upstream of implementation") and FORAY (in-engine reserved-location attestation) — 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 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; v0.1 said ~374-470) — 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.
Confirmed against Step 0 findings. Location: src/hooks/ does not exist today (Step 0); 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).
ChatView into the shared, turn-type-agnostic hook.ChatView to consume the hook, preserving its current behaviour exactly. Principal regression surface (see risk).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.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).CenterPane's minimal per-turn results channel and render UploadResultCard from it (construction question 2). Drive the in-flight progress affordance from sending (Step 0 note).postUpload. Resolved above.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 — takes one UploadPerFileEntry, an engagementId (composer supplies engagement.project_id), and an optional callback.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.ChatView's upload path; add unit coverage for the hook (orchestration sequence, 401 and error paths, typed-message-preservation branch); add coverage for CenterPane draft persistence (restore, write, clear-on-send, clear-on-upload) and the CenterPane upload mount (button live, upload fires, results render). Full suite green before close.Each step is its own commit. Pre-flight precedes work; halt-and-surface rather than absorb friction silently. Uploads and draft persistence are isolated steps so a regression is attributable to one and reversible without losing the other.
ChatView. ChatView consumes the turn-type-agnostic hook; behaviour preserved exactly. Commit. Vitest green.CenterPane (alone). Wire composer-draft.ts into the composer, identical to ChatView. No upload work in this step. Verifiable on its own: type, navigate away, return, text restored; send clears it. Commit. This proves draft persistence before the upload mount touches the same surface.CenterPane. Wire the hook (now able to pass clearDraft like ChatView); mount the live UploadAttachmentButton; remove the disabled placeholder; add the minimal results channel and UploadResultCard rendering; drive the progress affordance from sending. Commit.ChatView upload behaviour unchanged: single-file, multi-file, folder, typed-message preservation, error banner, 401 silent path.CenterPane accepts uploads: attach affordance live (never disabled), files upload to the engine via the hook, per-file results render via UploadResultCard, non-empty typed draft preserved as an Operator turn.CenterPane draft persists: survives navigation away and back; clears on send and on upload-fire; keyed per engagement so drafts do not collide across engagements.Two regression surfaces, deliberately isolated by step:
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).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 to the same surface.The eight engine upload-test fixtures recently brought to the production envelope guard the engine side and are unaffected; they do not guard this frontend work, which is why Step 4 adds frontend coverage.
Tracked as first-class queued-directions §15.3 entries; named for boundary clarity; none gate this work.
UploadEventReceived Memory event with provenance but drafts no individually-affirmable Assertions. Engine work, present on the path ChatView uses today, unchanged by this frontend work.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