@softize/opus 9.1.0 → 9.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/ui/components/primitives/ask.tsx +186 -0
- package/src/ui/docs/content/ask.md +31 -0
- package/src/ui/docs/registry.tsx +2 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,15 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
11
11
|
> tinha ficado sem registro nenhum, o que é exatamente o caso que este arquivo existe
|
|
12
12
|
> pra cobrir.
|
|
13
13
|
|
|
14
|
+
## 9.1.1 — 2026-08-20
|
|
15
|
+
|
|
16
|
+
**`Ask` leva elicitação estruturada para `@softize/opus/ui/react`.** O componente
|
|
17
|
+
controlado apresenta de uma a quatro `AskQuestion`, deriva `AskAnswer[]` sem tipos
|
|
18
|
+
paralelos e suporta seleção única por radiogroup, seleção múltipla por pills e texto livre
|
|
19
|
+
sempre disponível. Descrições usam Tooltip; teclado, validação, estados `disabled`/`busy`
|
|
20
|
+
e submit acessível ficam no componente. Transporte, SSE, persistência e integração com
|
|
21
|
+
`ChatEvent` continuam sob responsabilidade do consumidor.
|
|
22
|
+
|
|
14
23
|
## 9.1.0 — 2026-08-19
|
|
15
24
|
|
|
16
25
|
**Observabilidade ganha uma porta vendor-neutral no runtime.** `ObservabilityAdapter`
|
package/package.json
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { AskAnswer, AskQuestion } from '../../../core/index.ts'
|
|
3
|
+
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
import { Button } from './button.tsx'
|
|
5
|
+
import { Card } from './card.tsx'
|
|
6
|
+
import { Input } from './input.tsx'
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip.tsx'
|
|
8
|
+
|
|
9
|
+
export interface AskProps {
|
|
10
|
+
/** Uma a quatro perguntas do contrato de elicitação. */
|
|
11
|
+
questions: AskQuestion[]
|
|
12
|
+
/** Respostas controladas, na mesma ordem de `questions`. Entradas ausentes são vazias. */
|
|
13
|
+
answers: AskAnswer[]
|
|
14
|
+
onChange: (answers: AskAnswer[]) => void
|
|
15
|
+
/** Dispara somente quando toda pergunta tem uma opção ou texto livre. */
|
|
16
|
+
onSubmit: (answers: AskAnswer[]) => void
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
busy?: boolean
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function answerFor(question: AskQuestion, answer?: AskAnswer): AskAnswer {
|
|
23
|
+
const optionLabels = new Set(question.options.map((option) => option.label))
|
|
24
|
+
const selected = (answer?.selected ?? []).filter((label) => optionLabels.has(label))
|
|
25
|
+
return {
|
|
26
|
+
header: question.header?.trim() || question.question,
|
|
27
|
+
selected: question.multiSelect ? selected : selected.slice(0, 1),
|
|
28
|
+
...(answer?.text === undefined ? {} : { text: answer.text }),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isAnswered(answer: AskAnswer): boolean {
|
|
33
|
+
return answer.selected.length > 0 || (answer.text?.trim() ?? '') !== ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Elicitação estruturada controlada. Não executa transporte nem mantém estado de domínio. */
|
|
37
|
+
export function Ask({
|
|
38
|
+
questions,
|
|
39
|
+
answers,
|
|
40
|
+
onChange,
|
|
41
|
+
onSubmit,
|
|
42
|
+
disabled = false,
|
|
43
|
+
busy = false,
|
|
44
|
+
className,
|
|
45
|
+
}: AskProps): React.ReactElement {
|
|
46
|
+
const id = React.useId()
|
|
47
|
+
const questionSignature = JSON.stringify(questions)
|
|
48
|
+
const [submittedSignature, setSubmittedSignature] = React.useState<string>()
|
|
49
|
+
const submitted = submittedSignature === questionSignature
|
|
50
|
+
const normalized = questions.map((question, index) => answerFor(question, answers[index]))
|
|
51
|
+
const validCount = questions.length >= 1 && questions.length <= 4
|
|
52
|
+
const allAnswered = validCount && normalized.every(isAnswered)
|
|
53
|
+
const locked = disabled || busy
|
|
54
|
+
|
|
55
|
+
const changeAt = (index: number, next: AskAnswer): void => {
|
|
56
|
+
const nextAnswers = [...normalized]
|
|
57
|
+
nextAnswers[index] = next
|
|
58
|
+
onChange(nextAnswers)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Card
|
|
63
|
+
asChild
|
|
64
|
+
className={cn('p-4', className)}
|
|
65
|
+
aria-busy={busy || undefined}
|
|
66
|
+
aria-disabled={disabled || undefined}
|
|
67
|
+
>
|
|
68
|
+
<form
|
|
69
|
+
data-slot="ask"
|
|
70
|
+
noValidate
|
|
71
|
+
onSubmit={(event) => {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
setSubmittedSignature(questionSignature)
|
|
74
|
+
if (allAnswered && !locked) onSubmit(normalized)
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<TooltipProvider>
|
|
78
|
+
<div className="space-y-4">
|
|
79
|
+
{questions.map((question, questionIndex) => {
|
|
80
|
+
const answer = normalized[questionIndex]
|
|
81
|
+
const questionId = `${id}-question-${questionIndex}`
|
|
82
|
+
const errorId = `${id}-error-${questionIndex}`
|
|
83
|
+
const invalid = submitted && !isAnswered(answer)
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<fieldset key={questionId} disabled={locked} className="min-w-0 space-y-2.5">
|
|
87
|
+
<legend id={questionId} className="text-sm font-medium text-foreground">
|
|
88
|
+
{question.question}
|
|
89
|
+
</legend>
|
|
90
|
+
<div
|
|
91
|
+
role={question.multiSelect ? 'group' : 'radiogroup'}
|
|
92
|
+
aria-labelledby={questionId}
|
|
93
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
94
|
+
className="flex flex-wrap gap-2"
|
|
95
|
+
onKeyDown={question.multiSelect ? undefined : (event) => {
|
|
96
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
|
|
97
|
+
const radios = Array.from(
|
|
98
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'),
|
|
99
|
+
)
|
|
100
|
+
const current = radios.indexOf(document.activeElement as HTMLButtonElement)
|
|
101
|
+
if (current < 0 || radios.length === 0) return
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1
|
|
104
|
+
const next = radios[(current + direction + radios.length) % radios.length]
|
|
105
|
+
next.focus()
|
|
106
|
+
next.click()
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{question.options.map((option) => {
|
|
110
|
+
const selected = answer.selected.includes(option.label)
|
|
111
|
+
const firstSelected = answer.selected[0]
|
|
112
|
+
const firstOption = question.options[0]?.label
|
|
113
|
+
const optionButton = (
|
|
114
|
+
<Button
|
|
115
|
+
type="button"
|
|
116
|
+
size="sm"
|
|
117
|
+
shape="pill"
|
|
118
|
+
variant={selected ? 'secondary' : 'outline'}
|
|
119
|
+
role={question.multiSelect ? undefined : 'radio'}
|
|
120
|
+
aria-checked={question.multiSelect ? undefined : selected}
|
|
121
|
+
aria-pressed={question.multiSelect ? selected : undefined}
|
|
122
|
+
tabIndex={question.multiSelect
|
|
123
|
+
? undefined
|
|
124
|
+
: selected || (firstSelected === undefined && option.label === firstOption)
|
|
125
|
+
? 0
|
|
126
|
+
: -1}
|
|
127
|
+
disabled={locked}
|
|
128
|
+
onClick={() => {
|
|
129
|
+
const selectedOptions = question.multiSelect
|
|
130
|
+
? selected
|
|
131
|
+
? answer.selected.filter((label) => label !== option.label)
|
|
132
|
+
: [...answer.selected, option.label]
|
|
133
|
+
: [option.label]
|
|
134
|
+
changeAt(questionIndex, { ...answer, selected: selectedOptions })
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{option.label}
|
|
138
|
+
</Button>
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return option.description ? (
|
|
142
|
+
<Tooltip key={option.label}>
|
|
143
|
+
<TooltipTrigger asChild>{optionButton}</TooltipTrigger>
|
|
144
|
+
<TooltipContent>{option.description}</TooltipContent>
|
|
145
|
+
</Tooltip>
|
|
146
|
+
) : (
|
|
147
|
+
<React.Fragment key={option.label}>{optionButton}</React.Fragment>
|
|
148
|
+
)
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
<Input
|
|
152
|
+
value={answer.text ?? ''}
|
|
153
|
+
onChange={(event) => changeAt(questionIndex, { ...answer, text: event.target.value })}
|
|
154
|
+
placeholder="Outro (opcional)…"
|
|
155
|
+
aria-label={`Outra resposta para: ${question.question}`}
|
|
156
|
+
aria-invalid={invalid || undefined}
|
|
157
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
158
|
+
disabled={locked}
|
|
159
|
+
/>
|
|
160
|
+
{invalid && (
|
|
161
|
+
<p id={errorId} className="text-sm text-destructive">
|
|
162
|
+
Escolha uma opção ou escreva uma resposta.
|
|
163
|
+
</p>
|
|
164
|
+
)}
|
|
165
|
+
</fieldset>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
</TooltipProvider>
|
|
170
|
+
{!validCount && (
|
|
171
|
+
<p role="alert" className="mt-3 text-sm text-destructive">
|
|
172
|
+
Ask aceita de 1 a 4 perguntas.
|
|
173
|
+
</p>
|
|
174
|
+
)}
|
|
175
|
+
<div aria-live="polite" className="sr-only">
|
|
176
|
+
{submitted && !allAnswered ? 'Há perguntas sem resposta.' : ''}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="mt-4 flex justify-end">
|
|
179
|
+
<Button type="submit" size="sm" busy={busy} disabled={disabled || !validCount}>
|
|
180
|
+
Responder
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
</form>
|
|
184
|
+
</Card>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Elicitação estruturada controlada baseada diretamente em `AskQuestion` e `AskAnswer`. O componente só apresenta e valida respostas; transporte, persistência e continuação do agente pertencem ao consumidor.
|
|
2
|
+
|
|
3
|
+
```tsx preview
|
|
4
|
+
const [answers, setAnswers] = React.useState([])
|
|
5
|
+
|
|
6
|
+
render(
|
|
7
|
+
<Ask
|
|
8
|
+
questions={[
|
|
9
|
+
{
|
|
10
|
+
question: 'Qual ambiente devemos usar?',
|
|
11
|
+
header: 'Ambiente',
|
|
12
|
+
options: [
|
|
13
|
+
{ label: 'Produção', description: 'Usa dados e serviços reais.' },
|
|
14
|
+
{ label: 'Homologação', description: 'Usa o ambiente de validação.' },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
question: 'Quais verificações devo executar?',
|
|
19
|
+
header: 'Verificações',
|
|
20
|
+
multiSelect: true,
|
|
21
|
+
options: [{ label: 'Testes' }, { label: 'Typecheck' }, { label: 'Build' }],
|
|
22
|
+
},
|
|
23
|
+
]}
|
|
24
|
+
answers={answers}
|
|
25
|
+
onChange={setAnswers}
|
|
26
|
+
onSubmit={(next) => console.log(next)}
|
|
27
|
+
/>,
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Cada pergunta exige pelo menos uma opção válida selecionada **ou** texto livre não vazio. `busy` mostra o progresso no botão e trava os controles; `disabled` apenas trava a interação. A ordem de `answers` acompanha a ordem de `questions`, e o `header` de cada resposta é derivado de `question.header` (ou do próprio enunciado quando o header estiver vazio).
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -56,6 +56,7 @@ import buttonGroupMd from './content/button-group.md?raw'
|
|
|
56
56
|
import calendarMd from './content/calendar.md?raw'
|
|
57
57
|
import cardMd from './content/card.md?raw'
|
|
58
58
|
import carouselMd from './content/carousel.md?raw'
|
|
59
|
+
import askMd from './content/ask.md?raw'
|
|
59
60
|
import chatMd from './content/chat.md?raw'
|
|
60
61
|
import checkboxMd from './content/checkbox.md?raw'
|
|
61
62
|
import collapsibleMd from './content/collapsible.md?raw'
|
|
@@ -316,6 +317,7 @@ export const UI_SECTIONS: DocSection[] = [
|
|
|
316
317
|
{
|
|
317
318
|
label: 'IA',
|
|
318
319
|
pages: [
|
|
320
|
+
{ slug: 'ask', title: 'Ask', render: comp('Ask', 'ask', askMd) },
|
|
319
321
|
{ slug: 'chat', title: 'Chat', render: comp('Chat', 'chat', chatMd) },
|
|
320
322
|
{ slug: 'composer', title: 'Composer', render: comp('Composer', 'composer', composerMd) },
|
|
321
323
|
],
|
package/src/ui/meta.ts
CHANGED
|
@@ -11,6 +11,12 @@ import type { ComponentMeta } from './index.ts'
|
|
|
11
11
|
|
|
12
12
|
/** Todos os metas, chaveados pelo nome canônico (kebab). Fonte única — zero duplicação. */
|
|
13
13
|
export const componentMeta = {
|
|
14
|
+
'ask': {
|
|
15
|
+
name: 'ask',
|
|
16
|
+
ancestry: 'opus',
|
|
17
|
+
whenToUse:
|
|
18
|
+
'Elicitação estruturada controlada para 1–4 perguntas `AskQuestion`: opções single/multi em pills, texto livre opcional, validação e submit de `AskAnswer[]`. Não faz transporte, persistência nem integração automática com ChatEvent; o consumidor controla `answers`/`onChange` e conecta `onSubmit` ao canal apropriado.',
|
|
19
|
+
},
|
|
14
20
|
'alert': {
|
|
15
21
|
name: 'alert',
|
|
16
22
|
ancestry: 'opus',
|
package/src/ui/react.tsx
CHANGED
|
@@ -19,6 +19,8 @@ export { Input } from './components/primitives/input.tsx'
|
|
|
19
19
|
|
|
20
20
|
export { Chat } from './components/primitives/chat.tsx'
|
|
21
21
|
export type { ChatProps, ChatMessage, ChatArtifact, ChatTranscriptItem } from './components/primitives/chat.tsx'
|
|
22
|
+
export { Ask } from './components/primitives/ask.tsx'
|
|
23
|
+
export type { AskProps } from './components/primitives/ask.tsx'
|
|
22
24
|
export { Composer } from './components/primitives/composer.tsx'
|
|
23
25
|
export type { ComposerProps } from './components/primitives/composer.tsx'
|
|
24
26
|
export { Copyable } from './components/primitives/copyable.tsx'
|