Skip to content

feat(chat): render inline question tags from the agent in chat#5406

Open
emir-karabeg wants to merge 3 commits into
stagingfrom
feat/mothership-questions
Open

feat(chat): render inline question tags from the agent in chat#5406
emir-karabeg wants to merge 3 commits into
stagingfrom
feat/mothership-questions

Conversation

@emir-karabeg

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a <question> special tag to the chat stream: single_select, confirm, and text questions render as an inline div with the user input's chrome (rounded-2xl, --border-1)
  • Tag body accepts a single question object or an array; arrays render as one multi-step div with a ‹ 1 of 3 › stepper and back-navigation before submit
  • Option rows match the suggested-actions rows (numbered, divider-separated, trailing arrow); single_select always appends a "Something else" free-text row, text renders only the input
  • Answering the last question sends one combined user message (Prompt — Answer per line) through the existing option-select path and collapses the div to a question/answer recap
  • X dismisses silently; older messages render the div inert, matching the existing options tag behavior
  • Adds ChevronLeft/ChevronRight to @sim/emcn icons (rotations of the existing ChevronDown caret)
  • Aligns the credential connect row and usage-limit banner to the same rounded-2xl radius; strips question tags from copy-to-plaintext

Type of Change

  • New feature

Testing

17 new unit tests for the tag parser (object/array bodies, invalid schemas, streaming suppression) and the answer-message formatter; drove all states in the running app (light + dark). Lint and strict API validation pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 4, 2026 12:56am

Request Review

@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New interactive chat surface that shapes user messages sent back to the agent, but it reuses the existing option-select flow with strict client-side JSON validation and no backend/auth changes.

Overview
Adds a <question> streamed special tag so the agent can collect answers inline in chat. The tag body is JSON for single_select, confirm, or text questions (one object or an ordered array for multi-step flows). parseQuestionTagBody validates the schema; invalid bodies are dropped while surrounding text is kept, with the same streaming/pending behavior as other special tags.

QuestionDisplay renders the prompt with option rows styled like suggested follow-ups, optional “Something else” free text, dismiss, and a N of M stepper when there are multiple questions. Submitting the last step sends one user message via the existing onOptionSelect path (plain answer for a single question, Prompt — Answer lines for batches), then shows a recap; older messages stay inert when onSelect is missing. Copy-to-plaintext now strips question tags.

Also exports ChevronLeft / ChevronRight in @sim/emcn for the stepper, nudges credential and usage-upgrade chrome to rounded-2xl, and adds unit tests for parsing and answer formatting.

Reviewed by Cursor Bugbot for commit 8a9be60. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8a9be60. Configure here.

type='button'
variant='ghost'
onClick={() => goToStep(step + 1)}
disabled={isLast || answers[step] === null}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inert multi-step hides later prompts

Medium Severity

When QuestionDisplay is inert (onSelect omitted on non-latest messages), the forward stepper stays disabled because answers[step] is always null, so multi-step <question> tags only show the first prompt. Inert <options> tags still list every choice, so historical batches lose questions 2+.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8a9be60. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in a6890d3. Inert renders now leave the forward chevron enabled so all prompts in a historical batch stay browsable; interactive renders still gate forward movement on the current question being answered.

data,
next.map((a) => a ?? '')
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Earlier edits keep stale answers

Medium Severity

In a multi-step batch, changing an answer on an earlier step updates only that index in answers. Later steps keep prior selections, and the user can step forward via the chevrons and submit from the last question without revisiting them, so formatQuestionAnswerMessage may combine a new early answer with outdated later ones.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8a9be60. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended behavior. Back-navigation is a review/edit affordance: changing an earlier answer shouldn't discard later answers the user already made deliberately (they stay visibly highlighted when stepping through, standard wizard semantics). Submit only fires by explicitly answering the last question, so nothing is sent without the user's final action.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a new <question> special tag to the chat stream, supporting single_select, confirm, and text question types that render as inline interactive cards with multi-step navigation, free-text input, and a recap collapse on submission.

  • Adds QuestionDisplay component with stepper navigation, phase state (activeanswered / dismissed), and answer aggregation that sends one combined user message on completion.
  • Introduces parseQuestionTagBody with strict schema validation (type guard, prompt, options per type), integrates question into all existing special-tag pipelines (parsing, rendering, copy-to-plaintext stripping), and ships 17 new unit tests covering parsing and formatting.
  • Adds ChevronLeft/ChevronRight to @sim/emcn, and aligns CredentialDisplay and UsageUpgradeDisplay border radii to rounded-2xl.

Confidence Score: 4/5

Safe to merge; the feature is well-isolated, properly tested, and the inert-mode behavior for old messages is correctly handled through the existing disabled-prop pattern.

The implementation is clean and the 17 new tests cover the important parsing and formatting paths. Two small issues were found: an implicit undefined return in formatQuestionAnswerMessage for answers[0] (guarded by callers today but fragile to reuse), and stepper chevrons that rely on derived state rather than an explicit disabled guard to stay inert on older messages.

question.tsx — the formatQuestionAnswerMessage return and the stepper chevron disabled conditions both warrant a second look before the function is reused elsewhere.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx Core QuestionDisplay component and formatQuestionAnswerMessage utility; logic is correct but formatQuestionAnswerMessage returns answers[0] without an undefined guard, and the stepper chevrons do not propagate the disabled prop.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx parseQuestionTagBody, isQuestionItem, and SpecialTags renderer wired correctly; question added to all required tag lists and the type union; visual alignment changes to CredentialDisplay and UsageUpgradeDisplay are intentional.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts Good coverage of parseQuestionTagBody edge cases (invalid type, empty prompt, missing options, partial streaming) and parseSpecialTags integration; all scenarios described in the PR are exercised.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts Tests formatQuestionAnswerMessage for single and multi-step cases; correctly exercises both code paths.
apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx question added to SPECIAL_TAGS regex for copy-to-plaintext stripping; one-line change is correct.
packages/emcn/src/icons/chevron-left.tsx New ChevronLeft SVG icon with correct path, aria-hidden, and currentColor stroke; follows existing icon conventions.
packages/emcn/src/icons/chevron-right.tsx New ChevronRight SVG icon mirroring ChevronLeft correctly; follows existing icon conventions.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as Agent (stream)
    participant Parser as parseSpecialTags
    participant ST as SpecialTags renderer
    participant QD as QuestionDisplay
    participant CB as onOptionSelect callback

    Agent->>Parser: chunk containing question tag
    Parser->>Parser: parseQuestionTagBody → QuestionItem[]
    Parser-->>ST: "segment { type: 'question', data: QuestionItem[] }"
    ST->>QD: "render(data, onSelect=onOptionSelect)"
    Note over QD: phase='active', step=0, answers=[null,...]

    loop For each step 0..N-1
        QD->>QD: user picks option / enters free text
        QD->>QD: "setAnswers[step]=answer → goToStep(step+1)"
    end

    QD->>QD: last step answered → setPhase('answered')
    QD->>CB: onSelect(formatQuestionAnswerMessage(questions, answers))
    CB-->>Agent: user message submitted

    Note over QD: renders Q/A recap (inert)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as Agent (stream)
    participant Parser as parseSpecialTags
    participant ST as SpecialTags renderer
    participant QD as QuestionDisplay
    participant CB as onOptionSelect callback

    Agent->>Parser: chunk containing question tag
    Parser->>Parser: parseQuestionTagBody → QuestionItem[]
    Parser-->>ST: "segment { type: 'question', data: QuestionItem[] }"
    ST->>QD: "render(data, onSelect=onOptionSelect)"
    Note over QD: phase='active', step=0, answers=[null,...]

    loop For each step 0..N-1
        QD->>QD: user picks option / enters free text
        QD->>QD: "setAnswers[step]=answer → goToStep(step+1)"
    end

    QD->>QD: last step answered → setPhase('answered')
    QD->>CB: onSelect(formatQuestionAnswerMessage(questions, answers))
    CB-->>Agent: user message submitted

    Note over QD: renders Q/A recap (inert)
Loading

Reviews (1): Last reviewed commit: "fix(chat): let inert multi-step question..." | Re-trigger Greptile

Comment on lines +12 to +15
export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string {
if (questions.length === 1) return answers[0]
return questions.map((q, i) => `${q.prompt} — ${answers[i]}`).join('\n')
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unsafe array access in single-question branch: answers[0] returns undefined when the caller passes an empty array, but the declared return type is string. TypeScript won't catch this without noUncheckedIndexedAccess. Adding an explicit guard closes the gap and makes the intent self-documenting.

Suggested change
export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string {
if (questions.length === 1) return answers[0]
return questions.map((q, i) => `${q.prompt}${answers[i]}`).join('\n')
}
export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string {
if (questions.length === 1) return answers[0] ?? ''
return questions.map((q, i) => `${q.prompt}${answers[i] ?? ''}`).join('\n')
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 85e8dec — the only caller passes a parallel non-empty array, but the guard is cheap and makes the exported helper safe standalone.

Comment on lines +122 to +151
<div className='flex items-center gap-2'>
<Button
type='button'
variant='ghost'
onClick={() => goToStep(step - 1)}
disabled={step === 0}
className={cn(
ICON_BUTTON_CLASSES,
'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
)}
>
<ChevronLeft className='h-[9px] w-[7px] text-[var(--text-icon)]' />
<span className='sr-only'>Previous question</span>
</Button>
<span className='whitespace-nowrap text-[var(--text-muted)] text-sm tabular-nums'>
{step + 1} of {data.length}
</span>
<Button
type='button'
variant='ghost'
onClick={() => goToStep(step + 1)}
// Inert renders (older messages) browse freely; interactive ones
// gate forward movement on the current question being answered.
disabled={isLast || (!disabled && answers[step] === null)}
className={cn(
ICON_BUTTON_CLASSES,
'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
)}
>
<ChevronRight className='h-[9px] w-[7px] text-[var(--text-icon)]' />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stepper chevrons not gated by the disabled prop

The back/forward chevrons only check step === 0 and isLast || answers[step] === null — they never check disabled. In inert mode (older messages, onSelect undefined) this works out because answers is all-null so the forward button is always disabled and step starts at 0 so back is also disabled, but the dependency is implicit. If the initialization logic ever changes (e.g. answers pre-populated from a persisted state), the stepper would become interactive on messages that should be read-only. Passing disabled={disabled || step === 0} and disabled={disabled || isLast || answers[step] === null} makes the invariant explicit.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional, and made explicit in a6890d3: in inert mode the stepper deliberately stays browsable so historical multi-step batches can show all their prompts (Bugbot flagged the opposite problem — inert renders trapping users on question 1). Stepping is pure local navigation; answering is what's disabled. The forward chevron now reads disabled={isLast || (!disabled && answers[step] === null)} with a comment stating the invariant.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant