@softize/opus 9.0.4 → 9.0.6

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,18 @@ 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.6 — 2026-08-18
15
+
16
+ **As falas do usuário no `Chat` voltam ao fluxo normal da conversa.** Elas usam fundo
17
+ discreto, abraçam o conteúdo até 88% da largura e deixam de ter borda, sombra, pin no topo
18
+ e truncamento. O scroll não precisa mais medir qual turno está pinado.
19
+
20
+ ## 9.0.5 — 2026-08-18
21
+
22
+ **Cards de usuário no `Chat` ficam planos enquanto percorrem o histórico.** A sombra passa
23
+ a indicar somente o turno efetivamente pinado no topo durante o scroll, em vez de elevar
24
+ todas as mensagens que apenas têm comportamento `sticky` disponível.
25
+
14
26
  ## 9.0.4 — 2026-08-18
15
27
 
16
28
  **Digitar no `Chat` deixa de reprocessar o histórico inteiro.** O transcript agora é uma
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "9.0.4",
3
+ "version": "9.0.6",
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,104 +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
- */
34
+ /** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico. */
52
35
  const UserTurn = React.memo(function UserTurn({ content }: { content: string }): React.ReactElement {
53
- const ref = React.useRef<HTMLDivElement>(null)
54
- const [overflows, setOverflows] = React.useState(false)
55
- const [expanded, setExpanded] = React.useState(false)
56
- const [cap, setCap] = React.useState<number>()
57
-
58
- // Teto do EXPANDIDO, em px, medido no scroll container — não em `vh`.
59
- //
60
- // A primeira tentativa usou `60vh`, e a revisão mostrou por que não serve: o bloco que
61
- // contém o sticky é o scroll container, cuja altura vem do CONSUMIDOR (`h-full`), não da
62
- // tela. Num contêiner mais baixo que 60vh — um `<Chat>` embutido num painel de 400px numa
63
- // tela de 1080, por exemplo — o card pinado fica mais alto que o próprio scroll port, e o
64
- // botão de fechar sai da região visível e NÃO volta enquanto o turno estiver em vista.
65
- // Era o mesmo defeito, alcançado por outra porta.
66
- //
67
- // CONTRATO que isto passa a cobrar: o `<Chat>` precisa de altura LIMITADA pelo pai. Já
68
- // era assim de fato (sem isso o `overflow-y-auto` e o `sticky` não significam nada), mas
69
- // agora tem dente: com altura auto, o container é dimensionado pelo CONTEÚDO, e aí capar
70
- // encolhe o conteúdo, que baixa o teto, que encolhe de novo — espiral, e provável
71
- // "ResizeObserver loop completed with undelivered notifications". Com `vh` não existia,
72
- // porque a viewport não depende do conteúdo.
73
- React.useLayoutEffect(() => {
74
- const scroll = ref.current?.closest('[data-slot="chat-scroll"]')
75
- if (!(scroll instanceof HTMLElement)) return
76
- const apply = (): void => setCap(Math.round(scroll.clientHeight * 0.6))
77
- apply()
78
- if (typeof ResizeObserver === 'undefined') return
79
- const ro = new ResizeObserver(apply)
80
- ro.observe(scroll)
81
- return () => ro.disconnect()
82
- }, [])
83
-
84
- React.useLayoutEffect(() => {
85
- if (expanded) return
86
- const el = ref.current
87
- if (el === null) return
88
- const measure = (): void => setOverflows(el.scrollHeight > el.clientHeight + 1)
89
- measure()
90
- // A largura muda o quebra-linha, e com ela a resposta: um texto que cabe no rail
91
- // aberto transborda com ele estreito. Sem observar, o botão some ou sobra na hora
92
- // errada — e o rail do Maestro é redimensionável.
93
- if (typeof ResizeObserver === 'undefined') return
94
- const ro = new ResizeObserver(measure)
95
- ro.observe(el)
96
- return () => ro.disconnect()
97
- }, [content, expanded])
98
-
99
36
  return (
100
- <div data-slot="chat-user" className="break-words rounded-xl border border-border bg-card px-3 py-2 text-sm leading-snug shadow-sm">
101
- <div
102
- ref={ref}
103
- data-slot="chat-user-body"
104
- // Teto NOS DOIS estados, e o do aberto não é zelo: soltar a altura recriava o
105
- // defeito que este componente veio matar, agora atrás de um clique — o card segue
106
- // `sticky` com fundo opaco e volta a pintar por cima das falas seguintes. Pior, o
107
- // botão de fechar é o último filho: com card mais alto que a viewport ele fica
108
- // abaixo da dobra, e o sticky só consegue subir a altura da RESPOSTA (centenas de
109
- // px contra milhares de card, no caso-alvo de brief enorme + resposta curta). A
110
- // pessoa abria o brief e ficava sem como fechar. Com teto, o card inteiro cabe e o
111
- // botão está sempre alcançável; o brief se lê rolando DENTRO dele.
112
- className={cn(expanded ? 'overflow-y-auto' : 'max-h-40 overflow-hidden')}
113
- style={expanded ? { maxHeight: cap } : undefined}
114
- >
115
- <Markdown content={content} />
116
- {/* Esmaecido DENTRO do body, colado no fim do texto: como irmão do botão ele caía
117
- ~28px abaixo do corte (mt-1 + linha do botão + py-2), e a maior parte dos 40px
118
- pintava a faixa do botão em vez do texto truncado. */}
119
- {overflows && !expanded && (
120
- <div className="pointer-events-none sticky bottom-0 -mt-10 h-10 bg-gradient-to-t from-card to-transparent" />
121
- )}
122
- </div>
123
- {overflows && (
124
- <button
125
- type="button"
126
- onClick={() => setExpanded((v) => !v)}
127
- className="mt-1 text-xs text-muted-foreground underline-offset-2 hover:underline"
128
- >
129
- {expanded ? 'Mostrar menos' : 'Mostrar mais'}
130
- </button>
131
- )}
37
+ <div
38
+ data-slot="chat-user"
39
+ className="w-fit max-w-[88%] self-end break-words rounded-xl bg-muted px-3 py-2 text-sm leading-snug"
40
+ >
41
+ <Markdown content={content} />
132
42
  </div>
133
43
  )
134
44
  })
@@ -180,8 +90,7 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<ChatEvent> {
180
90
  return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
181
91
  }
182
92
 
183
- /** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta)
184
- * 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). */
185
94
  function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> {
186
95
  const turns: Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> = []
187
96
  for (const item of items) {
@@ -216,7 +125,6 @@ const ChatTranscript = React.memo(function ChatTranscript({
216
125
  const scrollRef = React.useRef<HTMLDivElement>(null)
217
126
  const turns = React.useMemo(() => groupTurns(items), [items])
218
127
  const indicatorVisible = indicator !== undefined
219
-
220
128
  React.useEffect(() => {
221
129
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
222
130
  }, [items, indicator])
@@ -240,9 +148,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
240
148
  // curtas virava uma parede uniforme, sem começo nem fim visíveis.
241
149
  <div key={ti} className="flex flex-col gap-1">
242
150
  {turn.user !== null && turn.user.role === 'user' && (
243
- // Cabeçalho do turno: gruda no topo enquanto o turno está em vista (igual
244
- // Claude) — a mensagem do usuário num card, não num balão preenchido.
245
- <div className="sticky top-0 z-10 py-1.5">
151
+ <div data-slot="chat-turn-user" className="flex justify-end py-1.5">
246
152
  <UserTurn content={turn.user.content} />
247
153
  </div>
248
154
  )}
@@ -307,8 +213,8 @@ const ChatTranscript = React.memo(function ChatTranscript({
307
213
 
308
214
  /**
309
215
  * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
310
- * transcript em turnos com o cabeçalho sticky (a pergunta fica à vista enquanto a
311
- * 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
312
218
  * (nunca mensagem) e artefatos via `renderArtifact`. Dois modos:
313
219
  *
314
220
  * - **Autogerenciado** (`send`): o componente é dono da conversa; o `send` devolve