deepseek-harness/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md
Yichen Jiang 315fc9b16e fix(ui-conversation): address composer review findings
- drop the space-key debug probe from the production keymap
- give pastes their own undo boundary (PASTE_TAG via $addUpdateTag on the
  nested dispatch path), with an input-bar regression test
- claim decoration outranks text-ref entities on the leading-token seat:
  the entity transform skips the active claim token, restoring the warn
  color for lexicon-listed command names (probe-confirmed regression test)
- caret-only commits no longer advance draftRev or re-publish InputState;
  content changes still do (snapshot-built CAS spans stay valid)
- retire stale JSDoc/contract wording (paste-upgrade, set-invalid; the
  invalid bit only promises the render treatment)
- rename the keydown probe spec to keymap-routing and drop test dead code
- reference-composer e2e gains the #2813 type-ahead-of-chip gesture
- archive three superseded composer notes (Safari soft-wrap, text layers,
  decoration keys), rewrite the input-machine note's superseded half in
  place, and record the new behavior decisions in the Lexical note
2026-08-20 22:20:31 +08:00

5.4 KiB
Raw Blame History

Agent Note: Multi-line answers in the question composer

Status: implemented

English | 中文

Problem

ask_user_question offers a free-text answer beside the model's own options. On a question that carried options, that answer was a single-line <input>: a long sentence scrolled sideways inside one 24px line, Shift+Enter did nothing, and an answer with structure — two requirements, a short list, a paragraph — could not be typed at all. The optionless question already used a textarea, but a fixed 64–140px box that neither followed the draft nor opened wider.

The chat composer next to it grows with the draft and takes Shift+Enter as a newline. A user who has just typed a multi-line prompt there meets a field that silently flattens the same answer.

Decision

Both question shapes answer into one AnswerField: a <textarea rows={1}> sharing a single CSS-grid cell with a hidden mirror <div> that renders the draft plus a trailing newline.

The mirror sits in normal flow and so sizes the grid row; the textarea stretches to that row, and rows={1} keeps the control's own intrinsic height out of the row sizing, leaving the mirror the only input to the height. Soft wraps are invisible to a '\n' count, so the mirror is what makes a wrapped answer grow the box rather than scroll one line. The trailing newline covers the last line the textarea's caret can reach and the block container drops. Mirror and textarea must keep identical type, padding, and wrapping rules; a divergence sizes the box wrong for the text being typed.

Growth stops at the mirror's max-height of six lines, and past that the textarea scrolls itself. The mirror takes box-sizing: content-box against the card-wide border-box so that cap counts text lines rather than text plus padding: the optionless variant carries 16px of vertical padding, which under border-box spends two thirds of a line and delivers the last one as an 8px sliver, while the inline variant has no padding and would land on a different line count from the same declaration. It is the only scrollport in the stack: unlike the chat composer, this field paints its own glyphs, so there is no second layer whose scroll offset would have to match.

Enter continues the flow and submits the batch on the last question, Shift+Enter breaks the line, and the IME guard is unchanged — Enter during composition confirms the candidate without advancing. The variant prop names which of the two looks the field takes, so the field owns both and neither caller assembles one out of class names.

Alternatives considered

field-sizing: content. Rejected for the same reason the composer's Safari recovery rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is the technique the chat composer ran before its Lexical editor.

Resize in JS on every keystroke — set height: auto, read scrollHeight, write it back. Rejected: it pays two forced layouts per keystroke and reintroduces the stale-geometry class of defect the mirror avoids, in exchange for no capability the mirror lacks.

Reuse the InputBar stack verbatim. Rejected because that stack carries a decoration backdrop between mirror and textarea, which forces both layers into one outer scrollport so the caret and the glyphs cannot drift apart. This field has no backdrop, so letting the textarea own its own scroll removes the outer scrollport and that obligation with it.

A separate expand-to-dialog entry for long answers. Rejected as unnecessary: growing in place already satisfies the requirement, and a dialog would take the options the answer is an alternative to off screen at the moment the user is weighing them.

Uncapped growth. Rejected because the card tops out at min(60vh, 520px) and still owes that budget to the title, the option rows, and the footer actions; an unbounded field pushes the choices the answer belongs to out of view.

A per-variant cap that absorbs each variant's padding. Rejected because it couples the line count to a padding value: changing .customBlock's inset would silently change how many lines the field grows to. content-box states the intent once, in the units the cap is written in.

Testing

Component tests pin the round trip: both shapes render a textarea, the mirror follows the draft, Shift+Enter never advances the flow, and line breaks reach the answer batch verbatim. The assembled question-composer web e2e measures the live engine — a soft-wrapped draft grows the field without scrolling it, two Shift+Enter presses leave "\n\n" in a taller field with the question still open, and a draft past the cap scrolls instead of growing at exactly six text lines in both variants.

Consequences

An answer can now carry the structure the question asks for, and the field the user sees behaves like the chat composer above it. The cost is a second element per field and the standing obligation to keep mirror and textarea metrics identical, which the JSDoc at AnswerField states and the e2e growth assertion detects.

The single-select highlight item of the same issue is untouched: focusing the custom field still leaves the previously chosen option visually selected until the first character lands.