fix(ui): carry in-progress ask answers across a stream push - #34
fix(ui): carry in-progress ask answers across a stream push#34mattwilkinsonn wants to merge 5 commits into
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Greptile SummaryThis PR preserves locally staged ask choices across stream updates while respecting the server-owned answered state.
Confidence Score: 4/5The PR appears safe to merge, with only the previously reported metadata-preservation limitation remaining on an currently unwired update path. The server-answered gates now resolve the previously reported closed-ask editing failure, while the existing preservation logic still retains complete local ask metadata when only non-ID fields change; the diff documents that the update path capable of producing that state is not currently wired. Files Needing Attention: apps/ui/src/store.ts
|
| Filename | Overview |
|---|---|
| apps/ui/src/store.ts | Adds ask-state preservation, server-closure write guards, shape-aware reconciliation, and rollback protection. |
| apps/ui/src/components/ChannelView.tsx | Locks controls and removes the submit affordance when an ask is locally submitted or server-answered. |
| apps/ui/src/live/adapt.ts | Maps the server-owned answered flag into the UI ask model. |
| apps/ui/src/store.ask-race.test.ts | Covers stream-push preservation, authoritative closure, shape changes, and accepted-response rollback races. |
| apps/ui/src/live/comms-fake.ts | Extends ask fixtures to model answered, free-text, and revised-option wire states faithfully. |
Reviews (4): Last reviewed commit: "docs(ui): make the sameQuestions comment..." | Re-trigger Greptile
| const sameQuestions = (a: Ask, b: Ask) => | ||
| a.questions.length === b.questions.length && | ||
| a.questions.every((q, i) => { | ||
| const other = b.questions[i]; | ||
| return ( | ||
| other !== undefined && | ||
| q.questionId === other.questionId && | ||
| q.options.length === other.options.length && | ||
| q.options.every((o, j) => o.id === other.options[j]?.id) | ||
| ); | ||
| }); |
There was a problem hiding this comment.
When a message update retains the question and option IDs but changes question text, allowMultiple, or an option label while a local answer is staged, sameQuestions treats the asks as equivalent and preservation restores the complete old ask. The UI consequently shows stale text or labels or applies obsolete single/multi-select behavior.
| const sameQuestions = (a: Ask, b: Ask) => | |
| a.questions.length === b.questions.length && | |
| a.questions.every((q, i) => { | |
| const other = b.questions[i]; | |
| return ( | |
| other !== undefined && | |
| q.questionId === other.questionId && | |
| q.options.length === other.options.length && | |
| q.options.every((o, j) => o.id === other.options[j]?.id) | |
| ); | |
| }); | |
| const sameQuestions = (a: Ask, b: Ask) => | |
| a.questions.length === b.questions.length && | |
| a.questions.every((q, i) => { | |
| const other = b.questions[i]; | |
| return ( | |
| other !== undefined && | |
| q.questionId === other.questionId && | |
| q.question === other.question && | |
| q.allowMultiple === other.allowMultiple && | |
| q.options.length === other.options.length && | |
| q.options.every( | |
| (o, j) => | |
| o.id === other.options[j]?.id && | |
| o.label === other.options[j]?.label, | |
| ) | |
| ); | |
| }); |
The wire is atomic: one RespondToAsk per ask, issued only on the click that completes it. On a multi-question ask every choice but the last lives only in local state, and adoptComms replaced that state wholesale on every push - so a snapshot or tail event landing mid-ask discarded the user's clicks with no indication. preserveLocalAsks carries an unsubmitted, partially-answered ask across a push where the pushed ask carries no server answer and poses the same questions, so a server value stays authoritative. The follow-up commit supplies the predicate that decides "no server answer". This one leaves it as a chosen-ids scan, which is wrong for two shapes the server accepts and records with zero chosen ids: a deliberate skip, and a custom_text-only answer to a free-text question. Co-Authored-By: seal <noreply@sealedsecurity.com>
preserveLocalAsks carried an unshipped local answer across a stream push only where the server "had no value", and approximated that by scanning every question for empty chosenOptionIds. Two answer shapes the server accepts and records defeat the scan, both leaving a CLOSED ask with no chosen ids anywhere: a deliberate skip (an entry with no chosen ids and empty custom_text is an accepted skip satisfying the wire's coverage contract) and a custom_text-only answer to a free-text question, which carries no options to choose. Against either, the scan reported "no authoritative value" for an ask the server had already closed, restored stale local picks over it, and left the UI offering a completing click the server refuses with ErrConflict. Ask.answered is now on the wire and is the authority: the server flips it exactly once, in the same path that records the chosen ids (messages.go:435-438), so a chosen id cannot exist without it. Only the site testing the PUSHED ask consults the flag. The two asking about the local record keep the chosen-ids scan: the client never sets answered, so routing them through it would skip every ask and disable the carry entirely. The comms fake defaults answered to whether chosen recorded anything, encoding that server invariant once rather than in each fixture. Co-Authored-By: seal <noreply@sealedsecurity.com>
The reconciliation fix made `answered` authoritative when deciding which of two states wins, but left the same flag unconsumed by the write and render path: an adopted server-closed ask rendered every option enabled, and the completing click shipped a RespondToAsk the server refuses ErrConflict (messages.go:404-405). Gate answerAsk, submitAsk, and the render's locked() on it, so a closed ask is closed to this client whoever closed it - `submitted` sees only the responds THIS store issued. Pin the three guards that were mutation-dead: a pushed ask whose question set grew or was renamed, and the untouched-ask short-circuit that adopts by reference. The fake now throws on chosen-ids-with-answered:false rather than defaulting it, since one server write records the ids and sets the flag (messages.go:435-438), and correct the WHY above the preserve leg: a prev entry is the last adopted SERVER state, so answered rides through answerAsk's spread - the flag there describes the ask adopted, not the edit. Co-Authored-By: seal <noreply@sealedsecurity.com>
Two gaps the prior ask-race fix could not see: - `sameQuestions` compared question ids only, so a pushed ask that revised its OFFERED options under stable question ids kept the local pick and rendered withdrawn options — the completing click would then ship an option id the server no longer offers (ErrInvalidArgument). Widen the shape test to compare option ids; the options are part of an ask's shape, not decoration on it. - The rollback restored the pre-click ask whenever the pushed answers matched what was shipped, which is exactly the accepted-then-lost-reply case: the server COMMITTED our respond and published the update, but our RPC's own reply never landed, so the push carries OUR ids and `answered` is the only distinguishing flag. Guard the rollback site on `!current.answered` so a refusal never reopens a server-closed ask. Adds two red→green tests (mutation-checked) plus the comms-fake `optionIds` override that lets a test restate an ask with a revised option set. Also refreshes the messages.go line references the comments cite. Co-Authored-By: seal <noreply@sealedsecurity.com>
The doc-comment justified the widened shape compare by listing question text, option ids and option labels as all free to move under a stable question id -- but sameQuestions only compares the offered option ids, in order, and preserveLocalAsks carries the whole local ask forward when they line up. So a text/label/allowMultiple revision under stable ids is not distinguished and momentarily renders with the local wording. State that boundary directly: the option ids are the shape the compare checks; a non-id revision is deliberately not distinguished, tolerable only because the block-update path is unwired and such an edit self-corrects on resync. Note that wiring it live must overlay the local picks onto the pushed question objects so a server revision to any non-id field is adopted while the in-progress pick survives. Comment-only; no behavior change. 557 pass, typecheck + biome clean. Co-Authored-By: seal <noreply@sealedsecurity.com>
547df6f to
4e2f281
Compare
64b062c to
eaec144
Compare

Stacked on compass#31 (
compass-server-port-ask), which putsAsk.answeredon the wire. This is the consumer half: the UI fix that could not land without it.The bug
adoptCommsreplaces comms state wholesale on everySubscribeCommspush. The ask wire is atomic — oneRespondToAskper ask, issued only on the click that completes it — so on a multi-question ask every choice but the last lives only in client state. A snapshot or tail event landing mid-ask discarded the user's clicks silently, and the server could not send them back: it was never told.preserveLocalAskscarries an unsubmitted, partially-answered ask across a push where the server has no answer of its own, keeping a server value is authoritative exactly true.Why this was held, and what changed
The first commit's predicate approximated "the server has no value" by scanning every question for empty
chosenOptionIds. Two answer shapes the server accepts and records defeat that scan, both leaving a closed ask with no chosen ids anywhere:custom_textis an accepted skip that satisfies the wire's coverage-of-every-question contract.custom_text-only answer to a free-text question, which carries no options to choose.Against either, the scan reported "no authoritative value" for an ask the server had already closed, restored stale local picks over it, and left the UI offering a completing click the server refuses with
ErrConflict— trading one silent-loss bug for a narrower one.Ask.answeredis the authority. The server flips it exactly once, in the same path that records the chosen ids:Same code path, so a chosen id cannot exist without the flag — which is what makes the flag strictly stronger than any scan of the questions.
One non-obvious detail: only one of three call sites uses the flag
The old predicate had three callers, and routing them all through
answeredwould have been a bug. The client never setsansweredon an ask it builds, so it isfalseon every local record — a server-flag test there would skip every ask and disable the carry entirely.The two scans are the right question at their sites — they ask about the local record, not about server authority — and each carries a comment saying so.
Verification
Mutation-checked rather than asserted. Reverting only the predicate to the old chosen-ids scan, keeping the new tests:
Exactly the two defeating shapes go red and every pre-existing test stays green. The third new case (
answered: false, genuinely pending) stays green under both predicates — correctly: it guards the original bug, a case the old scan got right, so it is a regression guard rather than a test of this change. Stating that explicitly because a new test that passes under the old predicate is not evidence for the new one.Fixture fidelity
wireAskMessagenow defaultsansweredto whetherchosenrecorded anything. Swapping the predicate initially reddened a pre-existing test that pushed a recordedchosenOptionIdswhile leavingansweredfalse — a state the real server cannot hold, permessages.go:435-438above. The fixture was the unfaithful party, so the invariant is now encoded once in the builder instead of in each test, and no existing test's body changed.Not fixed here
submittedAskIdsis acreateSignal<Set<string>>— pure client memory. After a reload it is empty, so an already-answered ask renders answerable and the click fires a respond the server refuses. Same defect class, different surface, andansweredis what fixes it — but it is a separate change and this PR does not make it.Refs SEA-1533
Co-Authored-By: seal noreply@sealedsecurity.com
Update: option-revision authority + accepted-then-lost-reply guard
Two follow-up commits extend the same authority principle to the block-update path and the rollback site:
sameQuestionsnow compares the offered option ids, in order, not just question ids. The block-update RPC (go/internal/store/messages.go:151, :163-167) rewrites a message's whole block set requiring only thatask_idsurvive, so an option id can appear, vanish, or be replaced under a stable question id. Comparing question ids alone would leave the UI rendering a withdrawn option and shipping an id the server no longer offers (validateQuestionAnswer→ErrInvalidArgument). This path is designed-for, not yet wired — nothing calls the block-update RPC today..catchrollback insendAsknow guards on!current.answered, so an accepted-then-lost-reply race (the server accepted our respond, then the reply was lost and the promise rejected) cannot roll a server-closed ask back open.Two new mutation-checked tests in
store.ask-race.test.ts(a refusal after the server accepted does not reopen the closed ask;a pushed ask that revised its options beats the local pick) — each reddens on the reverted predicate.Verification (current head):
moon run compass-ui:test→ 557 pass / 0 fail / 32 files; typecheck + biome clean; pre-pushmoon cigreen (31 tasks).Open questions (parked for Matt — overnight)
The sole-review pass returned 0 high / 0 medium; three lows, all on the unwired block-update path, all failing in the safe direction (never renders a withdrawn option, never ships an invalid id). They pose one design decision, deferred rather than decided by proxy:
preserveLocalAsksmerge strategy on a block-update push. WhensameQuestionsmatches, the function currently carries the whole local ask forward (return { kind: "ask", ask: mine }). That preserves the in-progress pick but discards any server revision to a non-id field (question text, option labels,allowMultiple) under stable ids. Two options for when the path is wired live:chosenOptionIdsfrom the matching local question byquestionId. Adopts every server-revised field while keeping the pick. Reviewer's recommendation; my lean too, since it keeps "a server value is authoritative" true for non-id fields as well.allowMultipletosameQuestionsso any such revision is treated as a shape change (adopt-pushed, drop the local pick). Simpler, but drops the pick on a purely cosmetic edit.comms-fake.tswireAskMessagederives each option label from its id, so a relabeled-same-id option is unrepresentable — the one wire shape that would exercise the label-revision case in (1) is untestable by construction. Add anoptionLabelsoverride + a label-revision test alongside the block-update wiring.None of these is a live bug on the current wire; the doc-comment now states the boundary honestly (commit
64b062c). Filed here so the decision lands with the block-update wiring work, not silently inpreserveLocalAsks.