@softize/opus 9.0.7 → 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,22 @@ 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
+
22
+ ## 9.0.8 — 2026-08-18
23
+
24
+ **Scrollbars nativas passam a usar o acabamento discreto como parte do tema base.** Todo
25
+ documento que importa `@softize/opus/ui/theme.css` recebe o indicador fino, revelado no
26
+ hover ou foco, inclusive em conteúdo renderizado por portal. A classe
27
+ `scrollbar-subtle` deixa de ser necessária; iframes continuam isolados e precisam importar
28
+ o tema no próprio documento.
29
+
14
30
  ## 9.0.7 — 2026-08-18
15
31
 
16
32
  **Scrollbars nativas podem compartilhar um acabamento discreto e previsível.** A classe
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "9.0.7",
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",
@@ -133,7 +133,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
133
133
  <div
134
134
  ref={scrollRef}
135
135
  data-slot="chat-scroll"
136
- className="scrollbar-subtle flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
136
+ className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
137
137
  >
138
138
  {items.length === 0 && !indicatorVisible && greeting !== undefined && (
139
139
  <div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
@@ -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('')
package/src/ui/theme.css CHANGED
@@ -180,39 +180,39 @@
180
180
  }
181
181
  }
182
182
 
183
- /* Escopo de scrollbar discreta. Pode vestir o próprio scroller (como o Chat) ou um shell:
184
- * nesse caso todos os scrolls nativos descendentes compartilham o mesmo acabamento. Isso
185
- * elimina a variação do macOS entre raiz, elementos aninhados, mouse e trackpad sem trocar
186
- * o mecanismo nativo nem adicionar trabalho em JS. Firefox usa scrollbar-color;
187
- * Chromium/Safari usam os pseudos abaixo. Iframes continuam sendo documentos isolados. */
183
+ /* Scrollbar nativa da casa: todo scroller do documento compartilha o mesmo acabamento,
184
+ * inclusive conteúdo renderizado por portal. Isso elimina a variação do macOS entre raiz,
185
+ * elementos aninhados, mouse e trackpad sem trocar o mecanismo nativo nem adicionar trabalho
186
+ * em JS. Firefox usa scrollbar-color; Chromium/Safari usam os pseudos abaixo. Iframes
187
+ * continuam sendo documentos isolados e devem importar o tema no próprio documento. */
188
188
  @media (hover: hover) {
189
- .scrollbar-subtle,
190
- .scrollbar-subtle * {
189
+ :root,
190
+ :root * {
191
191
  scrollbar-color: transparent transparent;
192
192
  scrollbar-width: thin;
193
193
  }
194
- .scrollbar-subtle:is(:hover, :focus, :focus-within),
195
- .scrollbar-subtle *:is(:hover, :focus, :focus-within) {
194
+ :root:is(:hover, :focus, :focus-within),
195
+ :root *:is(:hover, :focus, :focus-within) {
196
196
  scrollbar-color: color-mix(in srgb, var(--muted-foreground) 35%, transparent) transparent;
197
197
  }
198
- .scrollbar-subtle::-webkit-scrollbar,
199
- .scrollbar-subtle *::-webkit-scrollbar {
198
+ :root::-webkit-scrollbar,
199
+ :root *::-webkit-scrollbar {
200
200
  width: 0.5rem;
201
201
  height: 0.5rem;
202
202
  }
203
- .scrollbar-subtle::-webkit-scrollbar-track,
204
- .scrollbar-subtle *::-webkit-scrollbar-track {
203
+ :root::-webkit-scrollbar-track,
204
+ :root *::-webkit-scrollbar-track {
205
205
  background: transparent;
206
206
  }
207
- .scrollbar-subtle::-webkit-scrollbar-thumb,
208
- .scrollbar-subtle *::-webkit-scrollbar-thumb {
207
+ :root::-webkit-scrollbar-thumb,
208
+ :root *::-webkit-scrollbar-thumb {
209
209
  border: 2px solid transparent;
210
210
  border-radius: 9999px;
211
211
  background-color: transparent;
212
212
  background-clip: content-box;
213
213
  }
214
- .scrollbar-subtle:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb,
215
- .scrollbar-subtle *:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb {
214
+ :root:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb,
215
+ :root *:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb {
216
216
  background-color: color-mix(in srgb, var(--muted-foreground) 35%, transparent);
217
217
  }
218
218
  }