@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
|
@@ -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
|
-
<
|
|
320
|
-
{items
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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
|
+
})
|