Skip to content

BUG: Free-form question card (kind: "text") renders no text input #121

Description

@jeonghun-jj-lee

Summary

A kind: "text" question card (the free-form input added in PR #113 / amicode#245) renders its question text but no visible text input. The user cannot answer and must dismiss the card. Choice questions in the same session work normally.

Approach

Fix three compounding failures in the text-card renderer path — each small, all in one PR: (1) CSS visibility: give the text-form its own visible idle state using design-system tokens; (2) container measurement: eliminate the race between resizeInput and the optionsHeight ResizeObserver; (3) focus routing: when kind === "text", bypass the option/customRef focus path and target the textarea directly. Together these guarantee the input is visible, sized, and focusable on mount.

Approaches Considered

  • All-three fix (chosen) — addresses the root causes end-to-end; each is a few lines in co-located files; they compound into a single symptom so shipping them together is coherent.
  • CSS-only — smallest diff but leaves the measurement race and focus issues latent; may still fail on slower machines or constrained viewports.
  • Rewrite text-card as a standalone component — clean separation but over-engineered for the bug; larger refactor risk, delays the fix.

Scope

In: session-question-dock.tsx (focus routing, measurement sync), message-part.css (new [data-slot="question-text-form"] rule). Out: the choice-card renderer (unchanged), the TUI/CLI text-card paths (reported working), schema/tool definition (correct as-is).


Root-cause analysis

The text-card rendering path exists (session-question-dock.tsx:601-631) and was merged in PR #113 (commit cf4f06b, 2026-08-03). Three independent failures compound into the "no visible input" symptom:

1. No CSS for the text-form wrapper (visibility)

File: packages/session-ui/src/components/message-part.css

The textarea lives inside <form data-slot="question-text-form">, which has zero CSS rules. The textarea itself uses data-slot="question-custom-input" — styled for the choice card's secondary "type your own answer" input:

[data-slot="question-custom-input"] {
  background: transparent;
  border: 0;
  box-shadow: none;
  /* visible only via placeholder + :focus-visible outline */
}

This is appropriate when the input is a secondary affordance below visible option buttons. But for kind: "text", the textarea is the only affordance. Transparent + borderless = invisible to the user.

2. Container measurement race (sizing)

File: packages/app/src/pages/session/composer/session-question-dock.tsx:208-213, 425-428, 430-434

The question-options container constrains children with:

style={{ "max-height": `${store.optionsHeight * (1 - hidden())}px` }}

optionsHeight is set by a ResizeObserver on optionsRef.scrollHeight (line 211). The textarea's height is set by resizeInput (height→0→scrollHeight) inside focusCustom's setTimeout(0) (line 431-434). Race condition: the ResizeObserver can fire before the setTimeout resolves, recording a container height that doesn't include the textarea's final dimensions.

Initial optionsHeight is 180px (line 84), which is adequate for a single-row textarea — but if the observer fires during the height=0 transient, the Math.max(height, scrollHeight) logic captures the textarea at zero and the container stays at 180 (fine) or, on a re-render, the observer sees the empty form and reports less.

3. Focus routing misses the textarea (interaction)

File: packages/app/src/pages/session/composer/session-question-dock.tsx:160-180

On mount, pickFocus() returns 0 (no options to scan). focus(0) at line 178 routes to:

const el = next === options().length ? customRef : optsRef[next]

With options().length === 0 and next === 0: 0 === 0 → true → targets customRef. But customRef is the "Type your own answer" button from the choice-card path, which does not render for text-kind questions (questionCustomRow returns false). So customRef is undefined; el?.focus() is a no-op.

The textarea has its own focusCustom ref callback that does independent focus via setTimeout — this works if the textarea has non-zero rendered dimensions. But if the container clips it or it's invisible, the browser may not give it focus.


Reproduction

  1. Start a session that triggers a kind: "text" question (e.g. the overture's name/affiliation ask, or any interview step that passes kind: "text" in the tool call).
  2. Observe: the question card header appears, but no visible input affordance below it.
  3. Expected: a bordered textarea with placeholder text, auto-focused, ready for typing.

Repro payload shape:

{
  "questions": [{
    "question": "What is your name?",
    "header": "Your name",
    "kind": "text",
    "options": []
  }]
}

Acceptance Criteria

  1. A kind: "text" question renders a visible textarea: it has a border, background tint or other affordance indicating "type here" in its idle (unfocused) state, following the design-system tokens (--v2-border-border-base, --radius-md, appropriate padding from the 4px grid).
  2. The textarea receives keyboard focus on mount — the user can type immediately without clicking.
  3. The textarea has non-zero height on mount (at least one row visible) regardless of content.
  4. Submitting non-empty text resolves the question (existing behavior, must not regress).
  5. Choice questions (kind: "choice" or absent) continue to render option buttons with the "Type your own answer" row per their existing behavior.
  6. Both light and dark themes — the text-form border/tint is visible and meets contrast requirements in both.

Fix direction (code pointers)

CSS — new rule in message-part.css (~line 1100)

Add a dedicated rule for the text-form when it's the primary input:

[data-slot="question-text-form"] {
  padding: 4px 8px;            /* --space-1 --space-2 */
}

[data-slot="question-text-form"] > [data-slot="question-custom-input"] {
  border: 1px solid var(--v2-border-border-base);
  border-radius: var(--radius-md);
  padding: 8px 12px;          /* --space-2 --space-3 */
  background: var(--v2-background-bg-layer-02);
  min-height: 36px;

  &:focus-visible {
    border-color: var(--v2-border-border-focus);
    outline: none;  /* border IS the ring for this input */
  }
}

This scopes the visible treatment to text-form only — the choice card's custom input stays transparent.

Measurement — session-question-dock.tsx (~line 430)

After resizeInput, notify the container:

const focusCustom = (el: HTMLTextAreaElement) => {
  setTimeout(() => {
    el.focus()
    resizeInput(el)
    // Sync the container measurement after the textarea has its final height
    if (optionsRef) {
      setStore("optionsHeight", (h) => Math.max(h, optionsRef!.scrollHeight))
    }
  }, 0)
}

Focus — session-question-dock.tsx (~line 171)

Short-circuit the focus routing for text-kind questions:

const focus = (i: number) => {
  // Text-card: the textarea is the only focusable; skip option routing
  if (text()) return  // focusCustom handles it via ref callback
  const next = clamp(i)
  setStore("focus", next)
  ...
}

Or keep a dedicated textRef and route to it explicitly.


Context

Diagnostics (from original filing)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions