* fix(web): keep the streaming question-form id stable (no churn)
`parsePartialQuestionForm` derived the form `id` from the streaming body
`id` token, but that arrives char-by-char and `parsePartialJson` repairs the
open string, so `form.id` churned (`"d"` → `"di"` → …). `id` keys the live,
still-editable Questions panel (ProjectView's questionFormKey, QuestionForm's
data-form-id / input names), so each partial-id change remounted the panel and
discarded in-progress answers. Derive the preview id from the open-tag attr
(complete the instant the tag streams) or the stable default instead; the
final parse (tryParseForm, after the close tag) still adopts the real body id.
This also keeps the earlier nested-id-masquerade guarantee (no body scan).
Follow-up to #3582. Regression: the form id stays `discovery` while the body
id token streams in.
* fix(web): adopt the question-form body id once complete (no preview→final remount)
Building on the char-churn fix: when a form omits the open-tag id attribute
but carries a body id, the preview used 'discovery' and the final parse
adopted the body id — a single remount at the close tag that could drop
answers. Adopt the body id as soon as its string literal is terminated
(depth-1, nested ids ignored); that value equals what tryParseForm assigns,
so preview id == final id and the panel never remounts on the swap. The id
settles early (it's a leading field), before any question is answerable.
Regressions: a partial body id is not adopted (no churn); a complete body id
is adopted and matches splitOnQuestionForms' final id; nested question ids are
ignored.
* fix(web): decouple the question-form React key from the parsed form id
Root-cause fix for the identity churn three reviewers flagged. The Questions
panel was keyed on `${conversation}:${message}:${form.id}`, so any change to the
parsed id remounted it and dropped in-progress answers. The streaming preview
shows the `discovery` fallback id until the body id arrives, and a form that
emits answerable questions before its id settles flips identity while the user
is mid-answer — exactly the remount they described.
There is at most one (first) form per assistant message, so the id is redundant
for uniqueness: `${conversation}:${message}` already identifies the occurrence,
and a distinct later form lives in its own message (its own key, still replays
the reveal). Extract buildQuestionFormKey (id-free) and unit-test that the key
is stable across a streaming id flip, distinct across messages, and null until
conversation/message/form are all present.
completeTopLevelString stays — it settles the *displayed* id early — but the id
no longer drives identity, so even the questions-before-id ordering can't
remount the panel.