@softize/opus 9.0.4 → 9.0.5

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,12 @@ 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.5 — 2026-08-18
15
+
16
+ **Cards de usuário no `Chat` ficam planos enquanto percorrem o histórico.** A sombra passa
17
+ a indicar somente o turno efetivamente pinado no topo durante o scroll, em vez de elevar
18
+ todas as mensagens que apenas têm comportamento `sticky` disponível.
19
+
14
20
  ## 9.0.4 — 2026-08-18
15
21
 
16
22
  **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.5",
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",
@@ -49,7 +49,13 @@ function defaultHumanizeTool(name: string): string {
49
49
  * dela sugere). A medida só vale COLAPSADO: aberto, `scrollHeight === clientHeight` e a
50
50
  * pergunta "transborda?" passaria a responder não, sumindo com o botão de fechar.
51
51
  */
52
- const UserTurn = React.memo(function UserTurn({ content }: { content: string }): React.ReactElement {
52
+ const UserTurn = React.memo(function UserTurn({
53
+ content,
54
+ pinned,
55
+ }: {
56
+ content: string
57
+ pinned: boolean
58
+ }): React.ReactElement {
53
59
  const ref = React.useRef<HTMLDivElement>(null)
54
60
  const [overflows, setOverflows] = React.useState(false)
55
61
  const [expanded, setExpanded] = React.useState(false)
@@ -97,7 +103,13 @@ const UserTurn = React.memo(function UserTurn({ content }: { content: string }):
97
103
  }, [content, expanded])
98
104
 
99
105
  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">
106
+ <div
107
+ 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
+ )}
112
+ >
101
113
  <div
102
114
  ref={ref}
103
115
  data-slot="chat-user-body"
@@ -216,10 +228,59 @@ const ChatTranscript = React.memo(function ChatTranscript({
216
228
  const scrollRef = React.useRef<HTMLDivElement>(null)
217
229
  const turns = React.useMemo(() => groupTurns(items), [items])
218
230
  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])
219
279
 
220
280
  React.useEffect(() => {
221
281
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
222
- }, [items, indicator])
282
+ measurePinnedTurn()
283
+ }, [items, indicator, measurePinnedTurn])
223
284
 
224
285
  return (
225
286
  <div
@@ -242,8 +303,13 @@ const ChatTranscript = React.memo(function ChatTranscript({
242
303
  {turn.user !== null && turn.user.role === 'user' && (
243
304
  // Cabeçalho do turno: gruda no topo enquanto o turno está em vista (igual
244
305
  // 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">
246
- <UserTurn content={turn.user.content} />
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} />
247
313
  </div>
248
314
  )}
249
315
  {turn.rest.map((item, i) => {