Chat: inline question cards in the scrollable stream, house-style options, compact answered Q→A #56

Closed
opened 2026-07-08 21:23:52 +02:00 by dominik.polakovics · 2 comments

This was generated by AI during triage.

Problem

When the agent asks a question (AskUserQuestion / plan approval), the chat web UI renders the picker inside the sticky bottom composer. A multi-question form is capped at half the viewport with its own inner scrollbar, so on mobile it effectively lays over the chat and the conversation behind it becomes unreachable. The pill-shaped option buttons also read poorly around two-line label + description content.

A second, related gap: once a question is answered, the transcript degrades it to a generic collapsed "AskUserQuestion" tool chip whose body is truncated raw JSON — what was asked and what was chosen is effectively unreadable in history, especially on mobile.

Reference experience: Claude Code's web interface (claude.ai/code), where questions are ordinary conversation content — inline in the one scrollable chat pane, options fully readable.

Decision record (grilled 2026-07-08)

Interviewed the maintainer through every design branch; all resolved:

  1. Placement — the pending question renders as an interactive card inline in the chat stream, scrolling with the conversation. All inner max-height scrollboxes on question/plan content are removed; the chat pane is the only scroll container.
  2. Composer while pending — collapses to a slim "waiting on your answer above" note plus the existing Interrupt button. No textarea (free text cannot answer a focused TUI picker; only an explicit "Other" option carries free text).
  3. Answered history — compact Q→A summary: question text plus the chosen answer(s); unchosen options are dropped. Multi-question forms show one Q→A pair per question; an "Other" answer shows the typed text; an interrupted/denied question shows a dismissed marker. Requires the backend to keep emitting the dialog (with its answer) instead of falling through to a raw tool chip.
  4. Plan dialogs — plan markdown renders in full inline, approve/keep-planning buttons at its end. No cap, no collapse.
  5. Auto-scroll — when the card appears and the user was following the live tail, scroll so the top of the card (the question text) is in view; if the user had scrolled up, leave them be — the existing jump-to-latest pill covers it.
  6. Tap semantics — single-select keeps submit-on-tap; multi-select and multi-question forms keep their explicit Submit with the existing completeness gating.
  7. Card style — house design language, not a pixel clone of claude.ai/code: rectangular cards on the app's control radius and warm tokens, bold label with the muted description always visible beneath, accent border + check for selected. The pill radius on options goes away. Descriptions become visible on all paths, including the flat multi-select one that currently drops them.

An earlier report that descriptions were "tooltip-only" traced to an old deployed build — not an issue on main.

Full specification in the agent brief comment below.

> *This was generated by AI during triage.* ## Problem When the agent asks a question (AskUserQuestion / plan approval), the chat web UI renders the picker inside the sticky bottom composer. A multi-question form is capped at half the viewport with its **own inner scrollbar**, so on mobile it effectively lays over the chat and the conversation behind it becomes unreachable. The pill-shaped option buttons also read poorly around two-line label + description content. A second, related gap: once a question is **answered**, the transcript degrades it to a generic collapsed "AskUserQuestion" tool chip whose body is truncated raw JSON — what was asked and what was chosen is effectively unreadable in history, especially on mobile. Reference experience: Claude Code's web interface (claude.ai/code), where questions are ordinary conversation content — inline in the one scrollable chat pane, options fully readable. ## Decision record (grilled 2026-07-08) Interviewed the maintainer through every design branch; all resolved: 1. **Placement** — the pending question renders as an interactive card **inline in the chat stream**, scrolling with the conversation. All inner `max-height` scrollboxes on question/plan content are removed; the chat pane is the only scroll container. 2. **Composer while pending** — collapses to a slim "waiting on your answer above" note plus the existing Interrupt button. No textarea (free text cannot answer a focused TUI picker; only an explicit "Other" option carries free text). 3. **Answered history** — compact **Q→A summary**: question text plus the chosen answer(s); unchosen options are dropped. Multi-question forms show one Q→A pair per question; an "Other" answer shows the typed text; an interrupted/denied question shows a dismissed marker. Requires the backend to keep emitting the dialog (with its answer) instead of falling through to a raw tool chip. 4. **Plan dialogs** — plan markdown renders **in full** inline, approve/keep-planning buttons at its end. No cap, no collapse. 5. **Auto-scroll** — when the card appears and the user was following the live tail, scroll so the **top of the card** (the question text) is in view; if the user had scrolled up, leave them be — the existing jump-to-latest pill covers it. 6. **Tap semantics** — single-select keeps submit-on-tap; multi-select and multi-question forms keep their explicit Submit with the existing completeness gating. 7. **Card style** — house design language, not a pixel clone of claude.ai/code: rectangular cards on the app's control radius and warm tokens, bold label with the muted description always visible beneath, accent border + check for selected. The pill radius on options goes away. Descriptions become visible on **all** paths, including the flat multi-select one that currently drops them. An earlier report that descriptions were "tooltip-only" traced to an old deployed build — not an issue on main. Full specification in the agent brief comment below.
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Render agent questions as inline cards in the scrollable chat stream (Claude-Code-web style), restyle options as house-style cards, and show answered questions as compact Q→A summaries instead of raw tool chips.

Current behavior:
The chat view swaps the composer textarea for a question panel while a dialog is pending. Multi-question forms are capped at ~50% viewport height with a nested scrollbar; plan markdown is capped at ~40%. The panel sits pinned over the lower chat, which on mobile hides the conversation. Option buttons are pill-shaped (radius-pill) segments; the flat multi-select path renders labels without their descriptions. Once a dialog tool acquires a result, the transcript translator stops emitting a dialog message and emits a generic tool message instead, so history shows only a collapsed chip named after the tool with truncated raw JSON input/output.

Desired behavior:

Pending questions:

  • The pending dialog (kinds question, plan, approval) renders as an interactive card inside the chat stream, at the position of its dialog message in the transcript (in practice the last item — the agent is blocked on it). It must not render twice when both the live pending-dialog field and a dialog stream message describe the same tool id.
  • No nested scroll containers: multi-question forms and plan markdown render at full height; the chat stream is the only scrollbar. Plan approval buttons sit at the end of the fully-rendered plan.
  • While a dialog is pending, the composer collapses to a one-line waiting note (pointing at the card above) plus the Interrupt button. No textarea. The existing degraded state (conversation state is question but no structured dialog is available) keeps its current locked note + Interrupt.
  • When the card appears and the viewport was following the live tail, scroll so the card's top (the question text) is visible; when the user has scrolled up into history, do not yank — the existing jump-to-latest affordance suffices.
  • Interaction semantics are unchanged: single-select submits on tap; multi-select and multi-question forms use their explicit Submit gated on completeness; the "Other" row keeps its free-text input; controls disable while an answer POST is in flight; selection state resets when the pending dialog's tool id changes.

Option cards (house style, not a claude.ai/code clone):

  • Options are full-width rectangular cards using the app's control radius and existing color tokens: bold label, muted description always visible beneath it on every path (single-select, flat multi-select, multi-question), selected state marked with accent border + check. Minimum 44px tap targets. No pill radius, no tooltips, no truncation of option text.

Answered questions in history:

  • An answered dialog renders as a compact, inert Q→A summary in the stream: the question text plus the chosen option label(s); for multi-question forms one Q→A pair per question; an "Other" answer shows the typed text; unchosen options are not shown.
  • A dialog resolved without an answer (interrupt/denial) renders the question with a dismissed marker instead of an answer.
  • This requires the transcript translation layer to emit a dialog message for answered dialog tools too, carrying the resolved answer (derived from the tool's recorded result; the recorded answer-intent spool may serve as fallback), instead of falling back to a generic tool message. The provider wire contract (Dialog / chat message types, mirrored in the web API types) gains the answered/answer shape; conversation-state derivation must still treat only unanswered trailing dialogs as the question state.

Key interfaces:

  • Provider chat message model: the dialog message kind and Dialog wire type — extend to represent a resolved dialog with its answer (per-question chosen indices/labels, other-text, dismissed flag). The web API mirror of these types follows.
  • Transcript translation (claudecode provider): the branch that currently demotes an answered dialog tool_use to a tool message — must emit the answered dialog message instead, while keeping state derivation (question only for pending) intact.
  • Chat view composition: the message renderer gains the interactive card (pending) and compact summary (answered) presentations; the composer's dialog branch shrinks to the waiting note + Interrupt.
  • Stylesheet: option/card styles move off the pill segment style onto card styling built from existing tokens (--radius-control, --card, --accent, muted text).

Acceptance criteria:

  • A pending single-select, multi-select, multi-question, and plan dialog each render as a card in the chat stream; the chat pane is the only scrollbar (no max-height/overflow scrollboxes inside the card).
  • The same dialog never renders twice (live field vs stream message dedupe by tool id).
  • While pending, the composer shows only the waiting note + Interrupt; the textarea returns once the dialog resolves.
  • Option descriptions are visible on all three picker paths; no tooltips or ellipsized option text anywhere; options are rectangular cards with visible selected state and ≥44px tap targets.
  • Single-select submits on first tap; multi-select/multi-question Submit stays disabled until every question is answered (and "Other" has text); in-flight answers disable the controls.
  • Plan dialogs render the full plan markdown inline with the action buttons after it.
  • With the view at the live tail, a newly arriving dialog scrolls the card's top into view; with the view scrolled up, position is preserved and the jump affordance appears as today.
  • After answering, the stream shows the compact Q→A summary (per-question pairs, other-text rendered, dismissed marker for interrupted/denied) — the raw "AskUserQuestion" JSON chip no longer appears for dialog tools.
  • Transcript translation tests cover: answered single-select, answered multi-question, other-text answer, and dismissed dialog emission; state derivation still reports question only while unanswered.
  • Web component tests cover the pending card render, the answered summary render, and the composer waiting state.
  • A short ADR documents the answered-dialog addition to the provider chat contract.

Out of scope:

  • Free-text answering from the composer while a question is pending (text→"Other" bridging).
  • Pixel-level mimicry of claude.ai/code styling or importing foreign design tokens.
  • Keyboard navigation/arrow-key selection for options.
  • Changes to the answer HTTP endpoint semantics or to the TUI keystroke translation of answers (reading results for display only).
  • Question rendering on other surfaces (home rail, run lists, notifications).
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Render agent questions as inline cards in the scrollable chat stream (Claude-Code-web style), restyle options as house-style cards, and show answered questions as compact Q→A summaries instead of raw tool chips. **Current behavior:** The chat view swaps the composer textarea for a question panel while a dialog is pending. Multi-question forms are capped at ~50% viewport height with a nested scrollbar; plan markdown is capped at ~40%. The panel sits pinned over the lower chat, which on mobile hides the conversation. Option buttons are pill-shaped (`radius-pill`) segments; the flat multi-select path renders labels without their descriptions. Once a dialog tool acquires a result, the transcript translator stops emitting a dialog message and emits a generic tool message instead, so history shows only a collapsed chip named after the tool with truncated raw JSON input/output. **Desired behavior:** *Pending questions:* - The pending dialog (kinds `question`, `plan`, `approval`) renders as an interactive card **inside the chat stream**, at the position of its dialog message in the transcript (in practice the last item — the agent is blocked on it). It must not render twice when both the live pending-dialog field and a dialog stream message describe the same tool id. - No nested scroll containers: multi-question forms and plan markdown render at full height; the chat stream is the only scrollbar. Plan approval buttons sit at the end of the fully-rendered plan. - While a dialog is pending, the composer collapses to a one-line waiting note (pointing at the card above) plus the Interrupt button. No textarea. The existing degraded state (conversation state is `question` but no structured dialog is available) keeps its current locked note + Interrupt. - When the card appears and the viewport was following the live tail, scroll so the card's top (the question text) is visible; when the user has scrolled up into history, do not yank — the existing jump-to-latest affordance suffices. - Interaction semantics are unchanged: single-select submits on tap; multi-select and multi-question forms use their explicit Submit gated on completeness; the "Other" row keeps its free-text input; controls disable while an answer POST is in flight; selection state resets when the pending dialog's tool id changes. *Option cards (house style, not a claude.ai/code clone):* - Options are full-width rectangular cards using the app's control radius and existing color tokens: bold label, muted description **always visible beneath it on every path** (single-select, flat multi-select, multi-question), selected state marked with accent border + check. Minimum 44px tap targets. No pill radius, no tooltips, no truncation of option text. *Answered questions in history:* - An answered dialog renders as a compact, inert Q→A summary in the stream: the question text plus the chosen option label(s); for multi-question forms one Q→A pair per question; an "Other" answer shows the typed text; unchosen options are not shown. - A dialog resolved without an answer (interrupt/denial) renders the question with a dismissed marker instead of an answer. - This requires the transcript translation layer to emit a dialog message for **answered** dialog tools too, carrying the resolved answer (derived from the tool's recorded result; the recorded answer-intent spool may serve as fallback), instead of falling back to a generic tool message. The provider wire contract (`Dialog` / chat message types, mirrored in the web API types) gains the answered/answer shape; conversation-state derivation must still treat only *unanswered* trailing dialogs as the question state. **Key interfaces:** - Provider chat message model: the dialog message kind and `Dialog` wire type — extend to represent a resolved dialog with its answer (per-question chosen indices/labels, other-text, dismissed flag). The web API mirror of these types follows. - Transcript translation (claudecode provider): the branch that currently demotes an answered dialog tool_use to a tool message — must emit the answered dialog message instead, while keeping state derivation (`question` only for pending) intact. - Chat view composition: the message renderer gains the interactive card (pending) and compact summary (answered) presentations; the composer's dialog branch shrinks to the waiting note + Interrupt. - Stylesheet: option/card styles move off the pill segment style onto card styling built from existing tokens (`--radius-control`, `--card`, `--accent`, muted text). **Acceptance criteria:** - [ ] A pending single-select, multi-select, multi-question, and plan dialog each render as a card in the chat stream; the chat pane is the only scrollbar (no `max-height`/`overflow` scrollboxes inside the card). - [ ] The same dialog never renders twice (live field vs stream message dedupe by tool id). - [ ] While pending, the composer shows only the waiting note + Interrupt; the textarea returns once the dialog resolves. - [ ] Option descriptions are visible on all three picker paths; no tooltips or ellipsized option text anywhere; options are rectangular cards with visible selected state and ≥44px tap targets. - [ ] Single-select submits on first tap; multi-select/multi-question Submit stays disabled until every question is answered (and "Other" has text); in-flight answers disable the controls. - [ ] Plan dialogs render the full plan markdown inline with the action buttons after it. - [ ] With the view at the live tail, a newly arriving dialog scrolls the card's top into view; with the view scrolled up, position is preserved and the jump affordance appears as today. - [ ] After answering, the stream shows the compact Q→A summary (per-question pairs, other-text rendered, dismissed marker for interrupted/denied) — the raw "AskUserQuestion" JSON chip no longer appears for dialog tools. - [ ] Transcript translation tests cover: answered single-select, answered multi-question, other-text answer, and dismissed dialog emission; state derivation still reports `question` only while unanswered. - [ ] Web component tests cover the pending card render, the answered summary render, and the composer waiting state. - [ ] A short ADR documents the answered-dialog addition to the provider chat contract. **Out of scope:** - Free-text answering from the composer while a question is pending (text→"Other" bridging). - Pixel-level mimicry of claude.ai/code styling or importing foreign design tokens. - Keyboard navigation/arrow-key selection for options. - Changes to the answer HTTP endpoint semantics or to the TUI keystroke translation of answers (reading results for display only). - Question rendering on other surfaces (home rail, run lists, notifications).
Author
Owner

This was generated by AI while landing a PR.

Land-PR audit — PR #60 (afk/56main)

Verdict: PASS.

Verification signal relied on: Forgejo Actions ci / nativegreen on head de2ab20 (4m32s). That is the repo's required check and covers the whole diff surface: Go tests, -tags ui build+tests against the built dist, golangci-lint, and the SPA's eslint/prettier/vitest/vite build. The hermetic ci-nix gate did not run — correctly: the diff touches no *.nix/flake.lock/go.mod/go.sum paths (checked). Nothing was re-run locally.

Checks:

  • Open, not a draft; single commit with Conventional Commits title feat(chat): ….
  • AFK contract: head afk/56, body carries a working Closes #56.
  • Mergeable: merges clean against current origin/main (git merge-tree), no conflict.
  • Diff review against the issue #56 decision record — all seven pinned decisions implemented:
    1. Pending dialog renders as an interactive card inline in the stream, deduped by tool_id against a matching stream message (card takes the transcript position when one matches, else appended).
    2. Composer collapses to a waiting note + the single Interrupt (moved out of DialogPanel — exactly one escape hatch); degraded question state untouched.
    3. Answered dialogs stay dialog messages: provider.Dialog.Outcome (nullable) derived in new dialogoutcome.go from the same compat §5 toolUseResult/toolDenialKind ground truth the intent backstop reads — no new Claude Code coupling; unreadable/denied/timeout resolutions degrade to Dismissed, never to a pending-looking nil. deriveState reports question only for Outcome == nil; internal/chat.lastDialog skips answered dialogs so AnswerDialog can't target a resolved picker.
    4. Plan markdown untruncated (planDialog drops the truncate call; question prompts stay exempt too since the prompt is the answers map key).
    5. Arrival scroll targets the card top only for a not-yet-seen tool_id while following the tail; scrolled-up readers are never yanked; seenDialogToolId resets on stream reset.
    6. Tap semantics unchanged (single-select tap-to-answer, gated Submit on multi paths, busy disable).
    7. Options are house-style cards on --radius-control; descriptions now visible on all three picker paths incl. the flat multi-select that dropped them; inner max-height scrollboxes removed.
  • ADR-0028 present (an explicit acceptance criterion); compat §5 doc notes the second reader.
  • Test coverage matches the acceptance criteria: Go outcome-shape + state-edge + live-fixture re-pins; web tests for card placement/dedupe/resolve, waiting-note composer, arrival scroll both ways, all answered-summary shapes, and toggle cards with descriptions.

Non-blocking observation: pendingCardFor matches by tool_id without checking outcome, so a transient race (answered stream message + stale pending_dialog field in one refetch window) could momentarily show the interactive card instead of the summary — self-heals on the answer-triggered refetch; display-only.

The PR body itself flags that no browser was available in the sandbox: a visual eyeball on the deployed build (mobile card arrival under the sticky header, option spacing, dark-mode tint) remains worthwhile after merge.

Merge is gated on the operator's explicit free-text go-ahead.

> *This was generated by AI while landing a PR.* ## Land-PR audit — PR #60 (`afk/56` → `main`) **Verdict: PASS.** **Verification signal relied on:** Forgejo Actions `ci / native` — **green** on head `de2ab20` (4m32s). That is the repo's required check and covers the whole diff surface: Go tests, `-tags ui` build+tests against the built dist, golangci-lint, and the SPA's eslint/prettier/vitest/`vite build`. The hermetic `ci-nix` gate did not run — correctly: the diff touches no `*.nix`/`flake.lock`/`go.mod`/`go.sum` paths (checked). Nothing was re-run locally. **Checks:** - Open, not a draft; single commit with Conventional Commits title `feat(chat): …`. - AFK contract: head `afk/56`, body carries a working `Closes #56`. - Mergeable: merges clean against current `origin/main` (`git merge-tree`), no conflict. - Diff review against the issue #56 decision record — all seven pinned decisions implemented: 1. Pending dialog renders as an interactive card inline in the stream, deduped by `tool_id` against a matching stream message (card takes the transcript position when one matches, else appended). 2. Composer collapses to a waiting note + the single Interrupt (moved out of DialogPanel — exactly one escape hatch); degraded question state untouched. 3. Answered dialogs stay dialog messages: `provider.Dialog.Outcome` (nullable) derived in new `dialogoutcome.go` from the same compat §5 `toolUseResult`/`toolDenialKind` ground truth the intent backstop reads — no new Claude Code coupling; unreadable/denied/timeout resolutions degrade to `Dismissed`, never to a pending-looking nil. `deriveState` reports `question` only for `Outcome == nil`; `internal/chat.lastDialog` skips answered dialogs so `AnswerDialog` can't target a resolved picker. 4. Plan markdown untruncated (`planDialog` drops the `truncate` call; question prompts stay exempt too since the prompt is the `answers` map key). 5. Arrival scroll targets the card top only for a not-yet-seen `tool_id` while following the tail; scrolled-up readers are never yanked; `seenDialogToolId` resets on stream reset. 6. Tap semantics unchanged (single-select tap-to-answer, gated Submit on multi paths, busy disable). 7. Options are house-style cards on `--radius-control`; descriptions now visible on all three picker paths incl. the flat multi-select that dropped them; inner `max-height` scrollboxes removed. - ADR-0028 present (an explicit acceptance criterion); compat §5 doc notes the second reader. - Test coverage matches the acceptance criteria: Go outcome-shape + state-edge + live-fixture re-pins; web tests for card placement/dedupe/resolve, waiting-note composer, arrival scroll both ways, all answered-summary shapes, and toggle cards with descriptions. **Non-blocking observation:** `pendingCardFor` matches by `tool_id` without checking `outcome`, so a transient race (answered stream message + stale `pending_dialog` field in one refetch window) could momentarily show the interactive card instead of the summary — self-heals on the answer-triggered refetch; display-only. The PR body itself flags that no browser was available in the sandbox: a visual eyeball on the deployed build (mobile card arrival under the sticky header, option spacing, dark-mode tint) remains worthwhile after merge. Merge is gated on the operator's explicit free-text go-ahead.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#56
No description provided.