@softize/opus 9.0.3 → 9.0.4

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,13 @@ 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.4 — 2026-08-18
15
+
16
+ **Digitar no `Chat` deixa de reprocessar o histórico inteiro.** O transcript agora é uma
17
+ fronteira memoizada separada do estado do composer, e cada bloco Markdown reaproveita o
18
+ resultado enquanto seu conteúdo não muda. Conversas longas mantêm o custo de digitação
19
+ constante; durante streaming, somente a mensagem alterada volta a passar pelo parser.
20
+
14
21
  ## 9.0.3 — 2026-08-12
15
22
 
16
23
  **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.4",
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,7 @@ 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({ content }: { content: string }): React.ReactElement {
53
53
  const ref = React.useRef<HTMLDivElement>(null)
54
54
  const [overflows, setOverflows] = React.useState(false)
55
55
  const [expanded, setExpanded] = React.useState(false)
@@ -131,7 +131,7 @@ function UserTurn({ content }: { content: string }): React.ReactElement {
131
131
  )}
132
132
  </div>
133
133
  )
134
- }
134
+ })
135
135
 
136
136
  export interface ChatProps {
137
137
  /** Modo AUTOGERENCIADO: envia a conversa (o histórico, já com a nova mensagem) e devolve
@@ -194,6 +194,117 @@ function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptIt
194
194
  return turns
195
195
  }
196
196
 
197
+ interface ChatTranscriptProps {
198
+ items: ChatTranscriptItem[]
199
+ indicator: string | null | undefined
200
+ greeting: string | undefined
201
+ renderArtifact: ChatProps['renderArtifact']
202
+ }
203
+
204
+ /**
205
+ * O transcript é uma fronteira de renderização: editar o composer não muda nenhuma destas
206
+ * props e, portanto, não deve percorrer nem reprocessar o histórico. Quando o transcript
207
+ * realmente muda (streaming/replay), o Markdown memoizado abaixo de cada item preserva as
208
+ * mensagens cujo conteúdo continua igual.
209
+ */
210
+ const ChatTranscript = React.memo(function ChatTranscript({
211
+ items,
212
+ indicator,
213
+ greeting,
214
+ renderArtifact,
215
+ }: ChatTranscriptProps): React.ReactElement {
216
+ const scrollRef = React.useRef<HTMLDivElement>(null)
217
+ const turns = React.useMemo(() => groupTurns(items), [items])
218
+ const indicatorVisible = indicator !== undefined
219
+
220
+ React.useEffect(() => {
221
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
222
+ }, [items, indicator])
223
+
224
+ return (
225
+ <div
226
+ ref={scrollRef}
227
+ data-slot="chat-scroll"
228
+ className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
229
+ >
230
+ {items.length === 0 && !indicatorVisible && greeting !== undefined && (
231
+ <div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
232
+ <div className="mb-2 text-4xl">✦</div>
233
+ <p className="text-sm leading-relaxed">{greeting}</p>
234
+ </div>
235
+ )}
236
+ {turns.map((turn, ti) => (
237
+ // Hierarquia do espaço: as falas de um mesmo turno são um raciocínio contínuo
238
+ // (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
239
+ // MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
240
+ // curtas virava uma parede uniforme, sem começo nem fim visíveis.
241
+ <div key={ti} className="flex flex-col gap-1">
242
+ {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">
246
+ <UserTurn content={turn.user.content} />
247
+ </div>
248
+ )}
249
+ {turn.rest.map((item, i) => {
250
+ if (item.role === 'artifact') {
251
+ return (
252
+ <div key={i} data-slot="chat-artifact" className="flex justify-start">
253
+ {renderArtifact !== undefined ? (
254
+ renderArtifact(item.artifact)
255
+ ) : (
256
+ <a
257
+ href={item.artifact.ref}
258
+ target="_blank"
259
+ rel="noreferrer"
260
+ className="rounded-xl border bg-card px-3.5 py-2 text-sm underline-offset-2 hover:underline"
261
+ >
262
+ {item.artifact.title ?? item.artifact.ref}
263
+ </a>
264
+ )}
265
+ </div>
266
+ )
267
+ }
268
+ if (item.role === 'error') {
269
+ return (
270
+ <div
271
+ key={i}
272
+ 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"
273
+ >
274
+ {item.content}
275
+ </div>
276
+ )
277
+ }
278
+ // O assistente responde direto no corpo, em Markdown (convenção da casa).
279
+ return (
280
+ <div key={i} className="break-words px-1 text-sm leading-snug">
281
+ <Markdown content={item.content} />
282
+ </div>
283
+ )
284
+ })}
285
+ </div>
286
+ ))}
287
+ {indicatorVisible && (
288
+ <div
289
+ data-slot="chat-activity"
290
+ className="flex items-center gap-2 py-1 text-xs text-muted-foreground"
291
+ >
292
+ <span className="flex gap-1">
293
+ {[0, 1, 2].map((i) => (
294
+ <span
295
+ key={i}
296
+ className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/50"
297
+ style={{ animationDelay: `${i * 150}ms` }}
298
+ />
299
+ ))}
300
+ </span>
301
+ <span>{indicator ?? 'Pensando…'}</span>
302
+ </div>
303
+ )}
304
+ </div>
305
+ )
306
+ })
307
+
197
308
  /**
198
309
  * Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
199
310
  * transcript em turnos com o cabeçalho sticky (a pergunta fica à vista enquanto a
@@ -229,17 +340,12 @@ export function Chat({
229
340
  const [input, setInput] = React.useState('')
230
341
  const [ownBusy, setOwnBusy] = React.useState(false)
231
342
  const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
232
- const scrollRef = React.useRef<HTMLDivElement>(null)
233
343
 
234
344
  const items = controlled ? messages : ownItems
235
345
  const busy = controlled ? (busyProp ?? false) : ownBusy
236
346
  // Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
237
347
  const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
238
348
 
239
- React.useEffect(() => {
240
- scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
241
- }, [items, indicator])
242
-
243
349
  const kickoffRan = React.useRef(false)
244
350
  React.useEffect(() => {
245
351
  if (controlled || kickoff === undefined || kickoffRan.current) return
@@ -312,86 +418,14 @@ export function Chat({
312
418
  }
313
419
  }
314
420
 
315
- const indicatorVisible = indicator !== undefined
316
-
317
421
  return (
318
422
  <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>
423
+ <ChatTranscript
424
+ items={items}
425
+ indicator={indicator}
426
+ greeting={greeting}
427
+ renderArtifact={renderArtifact}
428
+ />
395
429
  {/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
396
430
  liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
397
431
  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
+ })