Skip to content

Commit 8a9be60

Browse files
committed
feat(chat): render inline question tags from the agent in chat
1 parent 6bc70cb commit 8a9be60

11 files changed

Lines changed: 536 additions & 3 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
2222
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
2323
import { useFolderStore } from '@/stores/folders/store'
2424

25-
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
25+
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'
2626

2727
function toPlainText(raw: string): string {
2828
return (

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
22
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
33
export { ChatContent } from './chat-content'
44
export { Options } from './options'
5+
export { QuestionDisplay } from './question'
56
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { formatQuestionAnswerMessage, QuestionDisplay } from './question'
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { formatQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question'
6+
import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
7+
8+
const QUESTIONS: QuestionItem[] = [
9+
{
10+
type: 'single_select',
11+
prompt: 'How should I handle the duplicates?',
12+
options: [{ id: 'keep_newest', label: 'Keep the newest entry' }],
13+
},
14+
{
15+
type: 'confirm',
16+
prompt: 'Delete 4 archived workflows?',
17+
options: [
18+
{ id: 'yes', label: 'Delete them' },
19+
{ id: 'no', label: 'Cancel' },
20+
],
21+
},
22+
{ type: 'text', prompt: 'What time zone should the daily report run in?' },
23+
]
24+
25+
describe('formatQuestionAnswerMessage', () => {
26+
it('sends just the answer for a single question', () => {
27+
expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe(
28+
'Keep the newest entry'
29+
)
30+
})
31+
32+
it('sends one prompt-answer line per question for multi-step batches', () => {
33+
expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe(
34+
'How should I handle the duplicates? — Keep the newest entry\n' +
35+
'Delete 4 archived workflows? — Cancel\n' +
36+
'What time zone should the daily report run in? — EST'
37+
)
38+
})
39+
})
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { ArrowRight, Button, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn'
5+
import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
6+
7+
/**
8+
* Builds the single user message sent after the final question is answered.
9+
* A lone question sends just the answer text; a multi-step batch sends one
10+
* `Prompt — Answer` line per question.
11+
*/
12+
export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string {
13+
if (questions.length === 1) return answers[0]
14+
return questions.map((q, i) => `${q.prompt}${answers[i]}`).join('\n')
15+
}
16+
17+
/**
18+
* The free-text input's initial value when (re)visiting a question: restore a
19+
* previously typed answer, but not one that matches an option row (that row is
20+
* highlighted instead).
21+
*/
22+
function freeTextPrefillFor(question: QuestionItem, answer: string | null): string {
23+
if (!answer) return ''
24+
if (question.type === 'text') return answer
25+
return question.options?.some((o) => o.label === answer) ? '' : answer
26+
}
27+
28+
const OPTION_ROW_CLASSES =
29+
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors'
30+
31+
/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */
32+
const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0'
33+
34+
/** Leading number slot matching the suggested follow-ups rows. */
35+
function RowNumber({ value }: { value: number }) {
36+
return (
37+
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
38+
<span className='text-[var(--text-icon)] text-sm'>{value}</span>
39+
</div>
40+
)
41+
}
42+
43+
type QuestionPhase = 'active' | 'answered' | 'dismissed'
44+
45+
interface QuestionDisplayProps {
46+
data: QuestionItem[]
47+
/** Sends the combined answer as a user message; undefined renders the div inert. */
48+
onSelect?: (message: string) => void
49+
}
50+
51+
/**
52+
* Inline renderer for the `<question>` special tag: a chat-inline div with the
53+
* user input's chrome, the current question's prompt at the top left, dismiss
54+
* (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and
55+
* suggested-action option rows beneath. `single_select` always appends a
56+
* free-text "Something else" row; `text` renders only the free-text row.
57+
* Answers collect locally; answering the last question sends one combined
58+
* user message and collapses the div to a question/answer recap.
59+
*/
60+
export function QuestionDisplay({ data, onSelect }: QuestionDisplayProps) {
61+
const disabled = !onSelect
62+
const [phase, setPhase] = useState<QuestionPhase>('active')
63+
const [step, setStep] = useState(0)
64+
const [answers, setAnswers] = useState<(string | null)[]>(() => data.map(() => null))
65+
const [freeText, setFreeText] = useState('')
66+
67+
if (data.length === 0 || phase === 'dismissed') return null
68+
69+
const containerClasses =
70+
'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]'
71+
72+
if (phase === 'answered') {
73+
return (
74+
<div className={containerClasses}>
75+
{data.map((question, i) => (
76+
<div key={i} className='px-2 py-2'>
77+
<p className='text-[var(--text-primary)] text-sm'>{question.prompt}</p>
78+
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>{answers[i]}</p>
79+
</div>
80+
))}
81+
</div>
82+
)
83+
}
84+
85+
const question = data[step]
86+
const isLast = step === data.length - 1
87+
const options = question.type === 'text' ? [] : (question.options ?? [])
88+
const hasFreeText = question.type !== 'confirm'
89+
90+
const goToStep = (next: number) => {
91+
setStep(next)
92+
setFreeText(freeTextPrefillFor(data[next], answers[next]))
93+
}
94+
95+
const handleAnswer = (answer: string) => {
96+
const next = [...answers]
97+
next[step] = answer
98+
setAnswers(next)
99+
if (!isLast) {
100+
goToStep(step + 1)
101+
return
102+
}
103+
setPhase('answered')
104+
onSelect?.(
105+
formatQuestionAnswerMessage(
106+
data,
107+
next.map((a) => a ?? '')
108+
)
109+
)
110+
}
111+
112+
const canSubmitFreeText = !disabled && freeText.trim().length > 0
113+
114+
return (
115+
<div className={containerClasses}>
116+
<div className='flex items-center justify-between gap-2 px-2 py-2'>
117+
<p className='min-w-0 flex-1 break-words text-[var(--text-primary)] text-sm'>
118+
{question.prompt}
119+
</p>
120+
<div className='flex items-center gap-3'>
121+
{data.length > 1 && (
122+
<div className='flex items-center gap-2'>
123+
<Button
124+
type='button'
125+
variant='ghost'
126+
onClick={() => goToStep(step - 1)}
127+
disabled={step === 0}
128+
className={cn(
129+
ICON_BUTTON_CLASSES,
130+
'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
131+
)}
132+
>
133+
<ChevronLeft className='h-[9px] w-[7px] text-[var(--text-icon)]' />
134+
<span className='sr-only'>Previous question</span>
135+
</Button>
136+
<span className='whitespace-nowrap text-[var(--text-muted)] text-sm tabular-nums'>
137+
{step + 1} of {data.length}
138+
</span>
139+
<Button
140+
type='button'
141+
variant='ghost'
142+
onClick={() => goToStep(step + 1)}
143+
disabled={isLast || answers[step] === null}
144+
className={cn(
145+
ICON_BUTTON_CLASSES,
146+
'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
147+
)}
148+
>
149+
<ChevronRight className='h-[9px] w-[7px] text-[var(--text-icon)]' />
150+
<span className='sr-only'>Next question</span>
151+
</Button>
152+
</div>
153+
)}
154+
{!disabled && (
155+
<Button
156+
type='button'
157+
variant='ghost'
158+
onClick={() => setPhase('dismissed')}
159+
className={cn(
160+
ICON_BUTTON_CLASSES,
161+
'before:absolute before:inset-[-14px] before:content-[""]'
162+
)}
163+
>
164+
<X className='size-[14px] text-[var(--text-icon)]' />
165+
<span className='sr-only'>Dismiss</span>
166+
</Button>
167+
)}
168+
</div>
169+
</div>
170+
<div className='flex flex-col'>
171+
{options.map((option, i) => (
172+
<button
173+
key={option.id}
174+
type='button'
175+
disabled={disabled}
176+
onClick={() => handleAnswer(option.label)}
177+
className={cn(
178+
OPTION_ROW_CLASSES,
179+
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
180+
i > 0 && 'border-t',
181+
answers[step] === option.label && 'bg-[var(--surface-5)]'
182+
)}
183+
>
184+
<RowNumber value={i + 1} />
185+
<span className='flex-1 truncate text-[var(--text-body)] text-sm'>{option.label}</span>
186+
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
187+
</button>
188+
))}
189+
{hasFreeText && (
190+
<div className={cn(OPTION_ROW_CLASSES, options.length > 0 && 'border-t')}>
191+
<RowNumber value={options.length + 1} />
192+
<input
193+
type='text'
194+
value={freeText}
195+
disabled={disabled}
196+
onChange={(e) => setFreeText(e.target.value)}
197+
onKeyDown={(e) => {
198+
if (e.key === 'Enter' && canSubmitFreeText) {
199+
e.preventDefault()
200+
handleAnswer(freeText.trim())
201+
}
202+
}}
203+
placeholder={question.type === 'text' ? 'Type an answer' : 'Something else'}
204+
aria-label={question.prompt}
205+
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed'
206+
/>
207+
<button
208+
type='button'
209+
aria-label='Submit answer'
210+
disabled={!canSubmitFreeText}
211+
onClick={() => handleAnswer(freeText.trim())}
212+
className='disabled:cursor-default'
213+
>
214+
<ArrowRight
215+
className={cn(
216+
'size-[16px] shrink-0 transition-colors',
217+
canSubmitFreeText ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]'
218+
)}
219+
/>
220+
</button>
221+
</div>
222+
)}
223+
</div>
224+
</div>
225+
)
226+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export type {
66
MothershipErrorTagData,
77
OptionsTagData,
88
ParsedSpecialContent,
9+
QuestionItem,
10+
QuestionOption,
11+
QuestionTagData,
12+
QuestionType,
913
RuntimeSpecialTagName,
1014
UsageUpgradeAction,
1115
UsageUpgradeTagData,
@@ -17,9 +21,11 @@ export {
1721
PendingTagIndicator,
1822
parseFileTag,
1923
parseJsonTagBody,
24+
parseQuestionTagBody,
2025
parseSpecialTags,
2126
parseTagAttributes,
2227
parseTextTagBody,
28+
QUESTION_TYPES,
2329
SpecialTags,
2430
USAGE_UPGRADE_ACTIONS,
2531
WORKSPACE_RESOURCE_TAG_TYPES,

0 commit comments

Comments
 (0)