@softize/opus 9.0.8 → 9.0.9

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 CHANGED
@@ -11,6 +11,14 @@ 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.0.9 — 2026-08-19
15
+
16
+ **O composer do `Chat` passa a navegar pelas mensagens já enviadas.** Com o campo vazio,
17
+ `↑` recupera a mensagem mais recente e continua pelas anteriores; `↓` avança e, depois da
18
+ mais nova, devolve o rascunho. Enquanto há texto sendo editado, as setas preservam a
19
+ navegação normal da textarea. O `Composer` expõe callbacks opcionais para shells que
20
+ queiram fornecer outro histórico, sem mudar o comportamento padrão do componente isolado.
21
+
14
22
  ## 9.0.8 — 2026-08-18
15
23
 
16
24
  **Scrollbars nativas passam a usar o acabamento discreto como parte do tema base.** Todo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "9.0.8",
3
+ "version": "9.0.9",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -212,7 +212,8 @@ const ChatTranscript = React.memo(function ChatTranscript({
212
212
  })
213
213
 
214
214
  /**
215
- * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
215
+ * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha;
216
+ * com o campo vazio, ↑/↓ navegam pelas mensagens anteriores do usuário),
216
217
  * transcript em turnos, fala do usuário em fundo discreto, assistente em Markdown direto
217
218
  * no corpo, tool como indicador vivo
218
219
  * (nunca mensagem) e artefatos via `renderArtifact`. Dois modos:
@@ -246,11 +247,52 @@ export function Chat({
246
247
  const [input, setInput] = React.useState('')
247
248
  const [ownBusy, setOwnBusy] = React.useState(false)
248
249
  const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
250
+ const historyCursor = React.useRef<number | null>(null)
251
+ const historyDraft = React.useRef('')
249
252
 
250
253
  const items = controlled ? messages : ownItems
251
254
  const busy = controlled ? (busyProp ?? false) : ownBusy
252
255
  // Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
253
256
  const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
257
+ const inputHistory = React.useMemo(
258
+ () => items.filter((item): item is ChatMessage => item.role === 'user').map((item) => item.content),
259
+ [items],
260
+ )
261
+
262
+ function changeInput(value: string): void {
263
+ historyCursor.current = null
264
+ setInput(value)
265
+ }
266
+
267
+ function showPreviousInput(): boolean {
268
+ if (inputHistory.length === 0) return false
269
+
270
+ if (historyCursor.current === null) {
271
+ // Não rouba ↑ do cursor em mensagens novas ou multilinha. A navegação começa no
272
+ // composer vazio, como em shells; depois disso ↑/↓ pertencem ao histórico.
273
+ if (input !== '') return false
274
+ historyDraft.current = input
275
+ historyCursor.current = inputHistory.length - 1
276
+ } else if (historyCursor.current > 0) {
277
+ historyCursor.current -= 1
278
+ }
279
+
280
+ setInput(inputHistory[historyCursor.current])
281
+ return true
282
+ }
283
+
284
+ function showNextInput(): boolean {
285
+ if (historyCursor.current === null) return false
286
+
287
+ if (historyCursor.current < inputHistory.length - 1) {
288
+ historyCursor.current += 1
289
+ setInput(inputHistory[historyCursor.current])
290
+ } else {
291
+ historyCursor.current = null
292
+ setInput(historyDraft.current)
293
+ }
294
+ return true
295
+ }
254
296
 
255
297
  const kickoffRan = React.useRef(false)
256
298
  React.useEffect(() => {
@@ -264,6 +306,8 @@ export function Chat({
264
306
  async function submit(): Promise<void> {
265
307
  const text = input.trim()
266
308
  if (text === '' || busy) return
309
+ historyCursor.current = null
310
+ historyDraft.current = ''
267
311
  setInput('')
268
312
 
269
313
  if (controlled) {
@@ -339,8 +383,10 @@ export function Chat({
339
383
  {notice}
340
384
  <Composer
341
385
  value={input}
342
- onChange={setInput}
386
+ onChange={changeInput}
343
387
  onSubmit={() => void submit()}
388
+ onHistoryPrevious={showPreviousInput}
389
+ onHistoryNext={showNextInput}
344
390
  busy={busy}
345
391
  placeholder={placeholder}
346
392
  actions={composerActions}
@@ -12,6 +12,10 @@ export interface ComposerProps {
12
12
  onChange: (value: string) => void
13
13
  /** Enter (sem Shift) ou o botão enviar. Só dispara quando dá pra enviar (ver `submitDisabled`). */
14
14
  onSubmit: () => void
15
+ /** Navegação opcional pelo histórico do dono do composer. Retorne `true` quando a tecla
16
+ * foi consumida; o `<Chat>` usa isso para ↑/↓ sem interferir no cursor normal. */
17
+ onHistoryPrevious?: () => boolean
18
+ onHistoryNext?: () => boolean
15
19
  /** Trava o composer enquanto o turno/ação corre — o enviar vira spinner. */
16
20
  busy?: boolean
17
21
  /** Gate EXTRA de envio além de "vazio" e "busy" (ex.: falta escolher o app). Desabilita o
@@ -39,6 +43,8 @@ export function Composer({
39
43
  value,
40
44
  onChange,
41
45
  onSubmit,
46
+ onHistoryPrevious,
47
+ onHistoryNext,
42
48
  busy = false,
43
49
  submitDisabled = false,
44
50
  placeholder = 'Escreva uma mensagem…',
@@ -59,6 +65,14 @@ export function Composer({
59
65
  value={value}
60
66
  onChange={(e) => onChange(e.target.value)}
61
67
  onKeyDown={(e) => {
68
+ if (e.key === 'ArrowUp' && onHistoryPrevious?.()) {
69
+ e.preventDefault()
70
+ return
71
+ }
72
+ if (e.key === 'ArrowDown' && onHistoryNext?.()) {
73
+ e.preventDefault()
74
+ return
75
+ }
62
76
  if (e.key === 'Enter' && !e.shiftKey) {
63
77
  e.preventDefault()
64
78
  fire()
@@ -1,6 +1,6 @@
1
1
  ## Básico
2
2
 
3
- Um chat mínimo: lista de mensagens + composer. A conversa é gerenciada por dentro (estado, loading, auto-scroll; **Enter** envia, **Shift+Enter** quebra linha) — a inteligência vem da prop `send`. O `greeting` é o estado vazio (centrado; some quando a conversa começa e NÃO entra no transcript). Dê altura ao container.
3
+ Um chat mínimo: lista de mensagens + composer. A conversa é gerenciada por dentro (estado, loading, auto-scroll; **Enter** envia, **Shift+Enter** quebra linha) — a inteligência vem da prop `send`. Com o composer vazio, **↑** recupera as mensagens anteriores do usuário e **↓** volta em direção ao rascunho; durante a edição, as setas continuam movendo o cursor normalmente. O `greeting` é o estado vazio (centrado; some quando a conversa começa e NÃO entra no transcript). Dê altura ao container.
4
4
 
5
5
  ```tsx preview
6
6
  <div className="h-96 rounded-lg border">
@@ -1,6 +1,6 @@
1
1
  ## Básico
2
2
 
3
- A caixa de escrever da casa: textarea numa pílula elevada (`rounded-card` + `border` + `shadow-sm`), **Enter** envia / **Shift+Enter** quebra linha, enviar dentro. É o composer do [Chat](/components/chat) extraído — use SOZINHO quando há entrada de texto mas não um chat (ex.: criar uma sessão). Controlado: o dono do texto é você.
3
+ A caixa de escrever da casa: textarea numa pílula elevada (`rounded-card` + `border` + `shadow-sm`), **Enter** envia / **Shift+Enter** quebra linha, enviar dentro. É o composer do [Chat](/components/chat) extraído — use SOZINHO quando há entrada de texto mas não um chat (ex.: criar uma sessão). Controlado: o dono do texto é você. Os callbacks opcionais `onHistoryPrevious` e `onHistoryNext` permitem que esse dono consuma **↑/↓**; sem eles, as setas mantêm o comportamento nativo da textarea.
4
4
 
5
5
  ```tsx preview col
6
6
  const [text, setText] = React.useState('')