@softize/opus 9.0.3 → 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,19 @@ 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
+
20
+ ## 9.0.4 — 2026-08-18
21
+
22
+ **Digitar no `Chat` deixa de reprocessar o histórico inteiro.** O transcript agora é uma
23
+ fronteira memoizada separada do estado do composer, e cada bloco Markdown reaproveita o
24
+ resultado enquanto seu conteúdo não muda. Conversas longas mantêm o custo de digitação
25
+ constante; durante streaming, somente a mensagem alterada volta a passar pelo parser.
26
+
14
27
  ## 9.0.3 — 2026-08-12
15
28
 
16
29
  **O pre-push ignora worktrees internos do Maestro.** Projetos sob `.maestro/` pertencem
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "9.0.3",
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
- 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 @@ function UserTurn({ content }: { content: string }): React.ReactElement {
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"
@@ -131,7 +143,7 @@ function UserTurn({ content }: { content: string }): React.ReactElement {
131
143
  )}
132
144
  </div>
133
145
  )
134
- }
146
+ })
135
147
 
136
148
  export interface ChatProps {
137
149
  /** Modo AUTOGERENCIADO: envia a conversa (o histórico, já com a nova mensagem) e devolve
@@ -194,6 +206,171 @@ function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptIt
194
206
  return turns
195
207
  }
196
208
 
209
+ interface ChatTranscriptProps {
210
+ items: ChatTranscriptItem[]
211
+ indicator: string | null | undefined
212
+ greeting: string | undefined
213
+ renderArtifact: ChatProps['renderArtifact']
214
+ }
215
+
216
+ /**
217
+ * O transcript é uma fronteira de renderização: editar o composer não muda nenhuma destas
218
+ * props e, portanto, não deve percorrer nem reprocessar o histórico. Quando o transcript
219
+ * realmente muda (streaming/replay), o Markdown memoizado abaixo de cada item preserva as
220
+ * mensagens cujo conteúdo continua igual.
221
+ */
222
+ const ChatTranscript = React.memo(function ChatTranscript({
223
+ items,
224
+ indicator,
225
+ greeting,
226
+ renderArtifact,
227
+ }: ChatTranscriptProps): React.ReactElement {
228
+ const scrollRef = React.useRef<HTMLDivElement>(null)
229
+ const turns = React.useMemo(() => groupTurns(items), [items])
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])
279
+
280
+ React.useEffect(() => {
281
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
282
+ measurePinnedTurn()
283
+ }, [items, indicator, measurePinnedTurn])
284
+
285
+ return (
286
+ <div
287
+ ref={scrollRef}
288
+ data-slot="chat-scroll"
289
+ className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
290
+ >
291
+ {items.length === 0 && !indicatorVisible && greeting !== undefined && (
292
+ <div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
293
+ <div className="mb-2 text-4xl">✦</div>
294
+ <p className="text-sm leading-relaxed">{greeting}</p>
295
+ </div>
296
+ )}
297
+ {turns.map((turn, ti) => (
298
+ // Hierarquia do espaço: as falas de um mesmo turno são um raciocínio contínuo
299
+ // (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
300
+ // MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
301
+ // curtas virava uma parede uniforme, sem começo nem fim visíveis.
302
+ <div key={ti} className="flex flex-col gap-1">
303
+ {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} />
313
+ </div>
314
+ )}
315
+ {turn.rest.map((item, i) => {
316
+ if (item.role === 'artifact') {
317
+ return (
318
+ <div key={i} data-slot="chat-artifact" className="flex justify-start">
319
+ {renderArtifact !== undefined ? (
320
+ renderArtifact(item.artifact)
321
+ ) : (
322
+ <a
323
+ href={item.artifact.ref}
324
+ target="_blank"
325
+ rel="noreferrer"
326
+ className="rounded-xl border bg-card px-3.5 py-2 text-sm underline-offset-2 hover:underline"
327
+ >
328
+ {item.artifact.title ?? item.artifact.ref}
329
+ </a>
330
+ )}
331
+ </div>
332
+ )
333
+ }
334
+ if (item.role === 'error') {
335
+ return (
336
+ <div
337
+ key={i}
338
+ className="max-w-[88%] self-start rounded-xl border border-destructive/30 bg-destructive/10 px-3.5 py-2.5 text-sm text-destructive"
339
+ >
340
+ {item.content}
341
+ </div>
342
+ )
343
+ }
344
+ // O assistente responde direto no corpo, em Markdown (convenção da casa).
345
+ return (
346
+ <div key={i} className="break-words px-1 text-sm leading-snug">
347
+ <Markdown content={item.content} />
348
+ </div>
349
+ )
350
+ })}
351
+ </div>
352
+ ))}
353
+ {indicatorVisible && (
354
+ <div
355
+ data-slot="chat-activity"
356
+ className="flex items-center gap-2 py-1 text-xs text-muted-foreground"
357
+ >
358
+ <span className="flex gap-1">
359
+ {[0, 1, 2].map((i) => (
360
+ <span
361
+ key={i}
362
+ className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/50"
363
+ style={{ animationDelay: `${i * 150}ms` }}
364
+ />
365
+ ))}
366
+ </span>
367
+ <span>{indicator ?? 'Pensando…'}</span>
368
+ </div>
369
+ )}
370
+ </div>
371
+ )
372
+ })
373
+
197
374
  /**
198
375
  * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
199
376
  * transcript em turnos com o cabeçalho sticky (a pergunta fica à vista enquanto a
@@ -229,17 +406,12 @@ export function Chat({
229
406
  const [input, setInput] = React.useState('')
230
407
  const [ownBusy, setOwnBusy] = React.useState(false)
231
408
  const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
232
- const scrollRef = React.useRef<HTMLDivElement>(null)
233
409
 
234
410
  const items = controlled ? messages : ownItems
235
411
  const busy = controlled ? (busyProp ?? false) : ownBusy
236
412
  // Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
237
413
  const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
238
414
 
239
- React.useEffect(() => {
240
- scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
241
- }, [items, indicator])
242
-
243
415
  const kickoffRan = React.useRef(false)
244
416
  React.useEffect(() => {
245
417
  if (controlled || kickoff === undefined || kickoffRan.current) return
@@ -312,86 +484,14 @@ export function Chat({
312
484
  }
313
485
  }
314
486
 
315
- const indicatorVisible = indicator !== undefined
316
-
317
487
  return (
318
488
  <div data-slot="chat" className={cn('flex h-full flex-col', className)}>
319
- <div ref={scrollRef} data-slot="chat-scroll" className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4">
320
- {items.length === 0 && !indicatorVisible && greeting !== undefined && (
321
- <div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
322
- <div className="mb-2 text-4xl">✦</div>
323
- <p className="text-sm leading-relaxed">{greeting}</p>
324
- </div>
325
- )}
326
- {groupTurns(items).map((turn, ti) => (
327
- // Hierarquia do espaço: as falas de um mesmo turno são um raciocínio contínuo
328
- // (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
329
- // MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
330
- // curtas virava uma parede uniforme, sem começo nem fim visíveis.
331
- <div key={ti} className="flex flex-col gap-1">
332
- {turn.user !== null && turn.user.role === 'user' && (
333
- // Cabeçalho do turno: gruda no topo enquanto o turno está em vista (igual
334
- // Claude) — a mensagem do usuário num card, não num balão preenchido.
335
- <div className="sticky top-0 z-10 py-1.5">
336
- <UserTurn content={turn.user.content} />
337
- </div>
338
- )}
339
- {turn.rest.map((item, i) => {
340
- if (item.role === 'artifact') {
341
- return (
342
- <div key={i} data-slot="chat-artifact" className="flex justify-start">
343
- {renderArtifact !== undefined ? (
344
- renderArtifact(item.artifact)
345
- ) : (
346
- <a
347
- href={item.artifact.ref}
348
- target="_blank"
349
- rel="noreferrer"
350
- className="rounded-xl border bg-card px-3.5 py-2 text-sm underline-offset-2 hover:underline"
351
- >
352
- {item.artifact.title ?? item.artifact.ref}
353
- </a>
354
- )}
355
- </div>
356
- )
357
- }
358
- if (item.role === 'error') {
359
- return (
360
- <div
361
- key={i}
362
- className="max-w-[88%] self-start rounded-xl border border-destructive/30 bg-destructive/10 px-3.5 py-2.5 text-sm text-destructive"
363
- >
364
- {item.content}
365
- </div>
366
- )
367
- }
368
- // O assistente responde direto no corpo, em Markdown (convenção da casa).
369
- return (
370
- <div
371
- key={i}
372
- className="break-words px-1 text-sm leading-snug"
373
- >
374
- <Markdown content={item.content} />
375
- </div>
376
- )
377
- })}
378
- </div>
379
- ))}
380
- {indicatorVisible && (
381
- <div data-slot="chat-activity" className="flex items-center gap-2 py-1 text-xs text-muted-foreground">
382
- <span className="flex gap-1">
383
- {[0, 1, 2].map((i) => (
384
- <span
385
- key={i}
386
- className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/50"
387
- style={{ animationDelay: `${i * 150}ms` }}
388
- />
389
- ))}
390
- </span>
391
- <span>{indicator ?? 'Pensando…'}</span>
392
- </div>
393
- )}
394
- </div>
489
+ <ChatTranscript
490
+ items={items}
491
+ indicator={indicator}
492
+ greeting={greeting}
493
+ renderArtifact={renderArtifact}
494
+ />
395
495
  {/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
396
496
  liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
397
497
  app…); o <Composer> sozinho segue sendo o caminho de quem não tem chat. */}
@@ -10,6 +10,7 @@
10
10
  * A tipografia é delegada ao `prose` (@tailwindcss/typography), mapeado pros tokens da casa
11
11
  * no theme.css; as tags saem SEMÂNTICAS e sem classe.
12
12
  */
13
+ import * as React from 'react'
13
14
  import MarkdownIt from 'markdown-it'
14
15
 
15
16
  /** A instância ÚNICA — a doc (`DocMarkdown`) importa esta mesma. Duas instâncias com
@@ -21,7 +22,10 @@ export interface MarkdownProps {
21
22
  className?: string
22
23
  }
23
24
 
24
- export function Markdown({ content, className }: MarkdownProps): React.ReactElement {
25
+ export const Markdown = React.memo(function Markdown({
26
+ content,
27
+ className,
28
+ }: MarkdownProps): React.ReactElement {
25
29
  return (
26
30
  <div
27
31
  data-slot="markdown"
@@ -32,4 +36,4 @@ export function Markdown({ content, className }: MarkdownProps): React.ReactElem
32
36
  dangerouslySetInnerHTML={{ __html: markdownIt.render(content) }}
33
37
  />
34
38
  )
35
- }
39
+ })