@softize/opus 9.0.5 → 9.0.7

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,20 @@ 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.7 — 2026-08-18
15
+
16
+ **Scrollbars nativas podem compartilhar um acabamento discreto e previsível.** A classe
17
+ `scrollbar-subtle` veste um scroller ou todo um shell: o indicador fino aparece ao passar
18
+ o ponteiro ou navegar com teclado pela região, sem depender da preferência global de
19
+ barras do macOS e sem adicionar listeners ao caminho de scroll. O `Chat` já adota o novo
20
+ acabamento.
21
+
22
+ ## 9.0.6 — 2026-08-18
23
+
24
+ **As falas do usuário no `Chat` voltam ao fluxo normal da conversa.** Elas usam fundo
25
+ discreto, abraçam o conteúdo até 88% da largura e deixam de ter borda, sombra, pin no topo
26
+ e truncamento. O scroll não precisa mais medir qual turno está pinado.
27
+
14
28
  ## 9.0.5 — 2026-08-18
15
29
 
16
30
  **Cards de usuário no `Chat` ficam planos enquanto percorrem o histórico.** A sombra passa
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "9.0.5",
3
+ "version": "9.0.7",
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",
@@ -31,116 +31,14 @@ function defaultHumanizeTool(name: string): string {
31
31
  return 'Trabalhando…'
32
32
  }
33
33
 
34
- /**
35
- * A fala do usuário no topo do turno — o card que GRUDA (sticky) enquanto o turno está
36
- * em vista.
37
- *
38
- * Ela nasce com TETO de altura, e o motivo é concreto: um brief longo (o caso normal na
39
- * cabine do Maestro, onde a primeira mensagem é o enunciado inteiro da tarefa) ocupava a
40
- * sala toda — e, por ser sticky, ficava lá, cobrindo a resposta que a pessoa está
41
- * esperando ler. Quanto mais caprichado o enunciado, menos se via do trabalho.
42
- *
43
- * Truncar sozinho não serve: esta é a ÚNICA cópia do texto na tela, então o corte precisa
44
- * vir com o gesto de abrir. Colapsado mostra o começo com um esmaecido no pé; aberto
45
- * mostra tudo e continua sticky.
46
- *
47
- * O botão só aparece quando há de fato o que revelar — medido no DOM, não adivinhado por
48
- * contagem de caracteres, que erra com markdown (uma tabela ocupa muito mais que o texto
49
- * dela sugere). A medida só vale COLAPSADO: aberto, `scrollHeight === clientHeight` e a
50
- * pergunta "transborda?" passaria a responder não, sumindo com o botão de fechar.
51
- */
52
- const UserTurn = React.memo(function UserTurn({
53
- content,
54
- pinned,
55
- }: {
56
- content: string
57
- pinned: boolean
58
- }): React.ReactElement {
59
- const ref = React.useRef<HTMLDivElement>(null)
60
- const [overflows, setOverflows] = React.useState(false)
61
- const [expanded, setExpanded] = React.useState(false)
62
- const [cap, setCap] = React.useState<number>()
63
-
64
- // Teto do EXPANDIDO, em px, medido no scroll container — não em `vh`.
65
- //
66
- // A primeira tentativa usou `60vh`, e a revisão mostrou por que não serve: o bloco que
67
- // contém o sticky é o scroll container, cuja altura vem do CONSUMIDOR (`h-full`), não da
68
- // tela. Num contêiner mais baixo que 60vh — um `<Chat>` embutido num painel de 400px numa
69
- // tela de 1080, por exemplo — o card pinado fica mais alto que o próprio scroll port, e o
70
- // botão de fechar sai da região visível e NÃO volta enquanto o turno estiver em vista.
71
- // Era o mesmo defeito, alcançado por outra porta.
72
- //
73
- // CONTRATO que isto passa a cobrar: o `<Chat>` precisa de altura LIMITADA pelo pai. Já
74
- // era assim de fato (sem isso o `overflow-y-auto` e o `sticky` não significam nada), mas
75
- // agora tem dente: com altura auto, o container é dimensionado pelo CONTEÚDO, e aí capar
76
- // encolhe o conteúdo, que baixa o teto, que encolhe de novo — espiral, e provável
77
- // "ResizeObserver loop completed with undelivered notifications". Com `vh` não existia,
78
- // porque a viewport não depende do conteúdo.
79
- React.useLayoutEffect(() => {
80
- const scroll = ref.current?.closest('[data-slot="chat-scroll"]')
81
- if (!(scroll instanceof HTMLElement)) return
82
- const apply = (): void => setCap(Math.round(scroll.clientHeight * 0.6))
83
- apply()
84
- if (typeof ResizeObserver === 'undefined') return
85
- const ro = new ResizeObserver(apply)
86
- ro.observe(scroll)
87
- return () => ro.disconnect()
88
- }, [])
89
-
90
- React.useLayoutEffect(() => {
91
- if (expanded) return
92
- const el = ref.current
93
- if (el === null) return
94
- const measure = (): void => setOverflows(el.scrollHeight > el.clientHeight + 1)
95
- measure()
96
- // A largura muda o quebra-linha, e com ela a resposta: um texto que cabe no rail
97
- // aberto transborda com ele estreito. Sem observar, o botão some ou sobra na hora
98
- // errada — e o rail do Maestro é redimensionável.
99
- if (typeof ResizeObserver === 'undefined') return
100
- const ro = new ResizeObserver(measure)
101
- ro.observe(el)
102
- return () => ro.disconnect()
103
- }, [content, expanded])
104
-
34
+ /** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico. */
35
+ const UserTurn = React.memo(function UserTurn({ content }: { content: string }): React.ReactElement {
105
36
  return (
106
37
  <div
107
38
  data-slot="chat-user"
108
- className={cn(
109
- 'break-words rounded-xl border border-border bg-card px-3 py-2 text-sm leading-snug',
110
- pinned && 'shadow-sm',
111
- )}
39
+ className="w-fit max-w-[88%] self-end break-words rounded-xl bg-muted px-3 py-2 text-sm leading-snug"
112
40
  >
113
- <div
114
- ref={ref}
115
- data-slot="chat-user-body"
116
- // Teto NOS DOIS estados, e o do aberto não é zelo: soltar a altura recriava o
117
- // defeito que este componente veio matar, agora atrás de um clique — o card segue
118
- // `sticky` com fundo opaco e volta a pintar por cima das falas seguintes. Pior, o
119
- // botão de fechar é o último filho: com card mais alto que a viewport ele fica
120
- // abaixo da dobra, e o sticky só consegue subir a altura da RESPOSTA (centenas de
121
- // px contra milhares de card, no caso-alvo de brief enorme + resposta curta). A
122
- // pessoa abria o brief e ficava sem como fechar. Com teto, o card inteiro cabe e o
123
- // botão está sempre alcançável; o brief se lê rolando DENTRO dele.
124
- className={cn(expanded ? 'overflow-y-auto' : 'max-h-40 overflow-hidden')}
125
- style={expanded ? { maxHeight: cap } : undefined}
126
- >
127
- <Markdown content={content} />
128
- {/* Esmaecido DENTRO do body, colado no fim do texto: como irmão do botão ele caía
129
- ~28px abaixo do corte (mt-1 + linha do botão + py-2), e a maior parte dos 40px
130
- pintava a faixa do botão em vez do texto truncado. */}
131
- {overflows && !expanded && (
132
- <div className="pointer-events-none sticky bottom-0 -mt-10 h-10 bg-gradient-to-t from-card to-transparent" />
133
- )}
134
- </div>
135
- {overflows && (
136
- <button
137
- type="button"
138
- onClick={() => setExpanded((v) => !v)}
139
- className="mt-1 text-xs text-muted-foreground underline-offset-2 hover:underline"
140
- >
141
- {expanded ? 'Mostrar menos' : 'Mostrar mais'}
142
- </button>
143
- )}
41
+ <Markdown content={content} />
144
42
  </div>
145
43
  )
146
44
  })
@@ -192,8 +90,7 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<ChatEvent> {
192
90
  return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
193
91
  }
194
92
 
195
- /** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta)
196
- * o cabeçalho do turno fica sticky enquanto o turno está em vista. */
93
+ /** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta). */
197
94
  function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> {
198
95
  const turns: Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> = []
199
96
  for (const item of items) {
@@ -228,65 +125,15 @@ const ChatTranscript = React.memo(function ChatTranscript({
228
125
  const scrollRef = React.useRef<HTMLDivElement>(null)
229
126
  const turns = React.useMemo(() => groupTurns(items), [items])
230
127
  const indicatorVisible = indicator !== undefined
231
- const [pinnedTurn, setPinnedTurn] = React.useState<number | null>(null)
232
- const pinFrameRef = React.useRef<number | undefined>(undefined)
233
-
234
- const measurePinnedTurn = React.useCallback((): void => {
235
- const scroll = scrollRef.current
236
- if (scroll === null) return
237
-
238
- const rect = scroll.getBoundingClientRect()
239
- const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + 1)
240
- const header = hit?.closest<HTMLElement>('[data-slot="chat-turn-user"]') ?? null
241
- const index = Number(header?.dataset['turn'])
242
- const next = header !== null && Number.isInteger(index) ? index : null
243
- setPinnedTurn((current) => current === next ? current : next)
244
- }, [])
245
-
246
- const schedulePinnedTurnMeasure = React.useCallback((): void => {
247
- if (pinFrameRef.current !== undefined) return
248
- pinFrameRef.current = requestAnimationFrame(() => {
249
- pinFrameRef.current = undefined
250
- measurePinnedTurn()
251
- })
252
- }, [measurePinnedTurn])
253
-
254
- React.useLayoutEffect(() => {
255
- const scroll = scrollRef.current
256
- if (scroll === null) return
257
- measurePinnedTurn()
258
- scroll.addEventListener('scroll', schedulePinnedTurnMeasure, { passive: true })
259
- if (typeof ResizeObserver === 'undefined') {
260
- return () => {
261
- if (pinFrameRef.current !== undefined) {
262
- cancelAnimationFrame(pinFrameRef.current)
263
- pinFrameRef.current = undefined
264
- }
265
- scroll.removeEventListener('scroll', schedulePinnedTurnMeasure)
266
- }
267
- }
268
- const observer = new ResizeObserver(measurePinnedTurn)
269
- observer.observe(scroll)
270
- return () => {
271
- if (pinFrameRef.current !== undefined) {
272
- cancelAnimationFrame(pinFrameRef.current)
273
- pinFrameRef.current = undefined
274
- }
275
- observer.disconnect()
276
- scroll.removeEventListener('scroll', schedulePinnedTurnMeasure)
277
- }
278
- }, [turns.length, measurePinnedTurn, schedulePinnedTurnMeasure])
279
-
280
128
  React.useEffect(() => {
281
129
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
282
- measurePinnedTurn()
283
- }, [items, indicator, measurePinnedTurn])
130
+ }, [items, indicator])
284
131
 
285
132
  return (
286
133
  <div
287
134
  ref={scrollRef}
288
135
  data-slot="chat-scroll"
289
- className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
136
+ className="scrollbar-subtle flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
290
137
  >
291
138
  {items.length === 0 && !indicatorVisible && greeting !== undefined && (
292
139
  <div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
@@ -301,15 +148,8 @@ const ChatTranscript = React.memo(function ChatTranscript({
301
148
  // curtas virava uma parede uniforme, sem começo nem fim visíveis.
302
149
  <div key={ti} className="flex flex-col gap-1">
303
150
  {turn.user !== null && turn.user.role === 'user' && (
304
- // Cabeçalho do turno: gruda no topo enquanto o turno está em vista (igual
305
- // Claude) — a mensagem do usuário num card, não num balão preenchido.
306
- <div
307
- data-slot="chat-turn-user"
308
- data-turn={ti}
309
- data-pinned={pinnedTurn === ti ? 'true' : undefined}
310
- className="sticky top-0 z-10 py-1.5"
311
- >
312
- <UserTurn content={turn.user.content} pinned={pinnedTurn === ti} />
151
+ <div data-slot="chat-turn-user" className="flex justify-end py-1.5">
152
+ <UserTurn content={turn.user.content} />
313
153
  </div>
314
154
  )}
315
155
  {turn.rest.map((item, i) => {
@@ -373,8 +213,8 @@ const ChatTranscript = React.memo(function ChatTranscript({
373
213
 
374
214
  /**
375
215
  * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
376
- * transcript em turnos com o cabeçalho sticky (a pergunta fica à vista enquanto a
377
- * resposta rola), assistente em Markdown direto no corpo, tool como indicador vivo
216
+ * transcript em turnos, fala do usuário em fundo discreto, assistente em Markdown direto
217
+ * no corpo, tool como indicador vivo
378
218
  * (nunca mensagem) e artefatos via `renderArtifact`. Dois modos:
379
219
  *
380
220
  * - **Autogerenciado** (`send`): o componente é dono da conversa; o `send` devolve
package/src/ui/theme.css CHANGED
@@ -180,6 +180,43 @@
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. */
188
+ @media (hover: hover) {
189
+ .scrollbar-subtle,
190
+ .scrollbar-subtle * {
191
+ scrollbar-color: transparent transparent;
192
+ scrollbar-width: thin;
193
+ }
194
+ .scrollbar-subtle:is(:hover, :focus, :focus-within),
195
+ .scrollbar-subtle *:is(:hover, :focus, :focus-within) {
196
+ scrollbar-color: color-mix(in srgb, var(--muted-foreground) 35%, transparent) transparent;
197
+ }
198
+ .scrollbar-subtle::-webkit-scrollbar,
199
+ .scrollbar-subtle *::-webkit-scrollbar {
200
+ width: 0.5rem;
201
+ height: 0.5rem;
202
+ }
203
+ .scrollbar-subtle::-webkit-scrollbar-track,
204
+ .scrollbar-subtle *::-webkit-scrollbar-track {
205
+ background: transparent;
206
+ }
207
+ .scrollbar-subtle::-webkit-scrollbar-thumb,
208
+ .scrollbar-subtle *::-webkit-scrollbar-thumb {
209
+ border: 2px solid transparent;
210
+ border-radius: 9999px;
211
+ background-color: transparent;
212
+ background-clip: content-box;
213
+ }
214
+ .scrollbar-subtle:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb,
215
+ .scrollbar-subtle *:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb {
216
+ background-color: color-mix(in srgb, var(--muted-foreground) 35%, transparent);
217
+ }
218
+ }
219
+
183
220
  /* Markdown (componente `prose`) segue os tokens da casa — light/dark sozinho via as vars
184
221
  (que já viram no `.dark`), então dispensa o `prose-invert`. O corpo é muted (como era na
185
222
  versão à mão); headings/bold no foreground; bordas/regras no --border. */